一、加固壳的原理回顾 在深入脱壳之前,先理解壳是怎么保护 DEX 的。
一.1 壳的分类体系 Android 加固技术经历了多代演进,每一代都提升了脱壳的难度:
第一代:整体型加固(DEX 整体加密) ├── 代表:梆梆加固、早期爱加密、早期360加固 ├── 原理:整个 DEX 加密存放在 assets/ 或 lib/ 中 │ 壳 Application 在运行时一次性解密全部 DEX │ 通过 DexClassLoader 或 InMemoryDexClassLoader 加载 ├── 脱壳难度:★☆☆☆☆ (一次性 dump 即可) └── 代表 so:libSecShell.so, libjiagu.so 第二代:抽取型加固(方法指令抽取) ├── 代表:360加固(VMP Pro)、百度加固 ├── 原理:DEX 文件结构大部分完整,但每个方法的 code_item │ 中的指令被抽走(替换为 nop 或跳转到壳的代码) │ 方法第一次执行时,壳从加密数据中解密出真实指令并回填 ├── 脱壳难度:★★★☆☆ (需要主动调用所有方法或 Hook 方法执行) └── 代表 so:libjiagu.so (VMP), libbaiduprotect.so 第三代:VMP/Dex-to-C 加固(虚拟机保护) ├── 代表:360 VMP、梆梆 VMP、顶象加固 ├── 原理:将原始 DEX 的方法体转换为自定义虚拟机的 opcode │ 运行时由壳自带的 VM 解释器执行 │ 原始 Dalvik 字节码根本不存在于 final APK 中 ├── 脱壳难度:★★★★★ (需要逆向分析壳的 VM 解释器) └── 代表 so:libjiagu_vm.so, libSecShell_vm.so
一.2 典型壳 Application 代码 加固的本质是 DEX 加密存放 + 运行时解密加载 。因此脱壳的关键是:在 DEX 解密后、加载前,将其从内存中 dump 出来 。
public class ShellApplication extends Application { static { System.loadLibrary("shell" ); } @Override protected void attachBaseContext (Context base) { super .attachBaseContext(base); byte [] encryptedDex = readEncryptedDexFromAssets("classes.dat" ); byte [] realDex = nativeDecrypt(encryptedDex, encryptedDex.length); File dexFile = new File (getDir("dyn_dex" , 0 ), "real.dex" ); FileOutputStream fos = new FileOutputStream (dexFile); fos.write(realDex); fos.close(); DexClassLoader dcl = new DexClassLoader ( dexFile.getAbsolutePath(), getDir("opt_dex" , 0 ).getAbsolutePath(), getApplicationInfo().nativeLibraryDir, getClassLoader() ); replaceClassLoader(dcl); invokeOriginalApplication(); } }
一.3 DEX 在 ART 中的加载路径 理解 DEX 的加载过程是理解脱壳原理的前提:
ART Runtime 中的 DEX 加载路径: 1. DexClassLoader 构造函数 └→ BaseDexClassLoader.<init>(dexPath, ...) └→ DexPathList.<init>(dexPath, ...) └→ DexPathList.makeDexElements(...) └→ DexFile.loadDex(dexPath, ...) ← 入口 1 └→ DexFile.openDexFile(dexPath, ...) ← 入口 2 └→ DexFile::OpenCommon(file, ...) ← 入口 3 (Native) └→ DexFile::OpenFile(...) └→ DexFile::DexFile(byte*, size) ← 解析 DEX header └→ ClassLinker::RegisterDexFile(...) └→ 类方法注册 2. InMemoryDexClassLoader (Android 8.0+) └→ DexFile.<init>(ByteBuffer) ← 入口 4 └→ DexFile::OpenMemory(byte*, size) ← 入口 5 (Native) 3. defineClass / loadClass └→ ClassLinker::DefineClass(...) ← 入口 6 └→ 解析 class_def → 注册到 ClassTable └→ LoadMethod(...)
脱壳的关键 Hook 点正是这些入口函数。
二、ZjDroid 脱壳原理 ZjDroid(https://github.com/halfkiss/ZjDroid)是早期著名的 Xposed 脱壳模块,核心思路是 Hook ClassLoader 和 DexFile 相关方法 。
二.1 ZjDroid 架构 ZjDroid 模块结构: ├── AndroidManifest.xml ← 声明为 Xposed 模块 ├── assets/xposed_init ← 入口类 ├── com/ │ └── zjdroid/ │ ├── MainHook.java ← 主 Hook 入口 │ ├── DexDumper.java ← DEX dump 逻辑 │ ├── PacketHandler.java ← 广播接收处理 │ └── Utils.java ← 辅助工具函数 └── lib/ └── armeabi-v7a/ └── libdump.so ← Native dump 辅助
二.2 ZjDroid 的关键 Hook 点 Hook 点 1: dalvik.system.DexFile.<init>(String) 目的:捕获通过文件路径创建的 DEX 对象 时机:DexClassLoader 构造时 Hook 点 2: dalvik.system.DexFile.<init>(ByteBuffer) 目的:捕获通过内存加载的 DEX 对象(InMemoryDexClassLoader) 时机:Android 8.0+ 的内存 DEX 加载 Hook 点 3: dalvik.system.DexFile.loadClass(String, ClassLoader) 目的:监控类的加载过程 时机:每个类第一次被加载时 Hook 点 4: dalvik.system.BaseDexClassLoader.findClass() 目的:捕获类查找过程 时机:ClassLoader 解析类名时 Hook 点 5: java.lang.Runtime.exec() 目的:监控命令执行(检测反调试行为) 时机:应用执行 shell 命令时
二.3 ZjDroid 脱壳工作流 adb shell am broadcast -a com.zjdroid.invoke --ei target pid adb shell am broadcast -a com.zjdroid.invoke --ei target pid --es cmd '{"action":"dump_dexinfo"}' adb shell am broadcast -a com.zjdroid.invoke --ei target pid --es cmd '{"action":"dump_dex","dexpath":"/data/app/.../base.apk"}' adb shell am broadcast -a com.zjdroid.invoke --ei target pid --es cmd '{"action":"dump_all"}' adb shell am broadcast -a com.zjdroid.invoke --ei target pid --es cmd '{"action":"dump_class"}' adb pull /data/data/com.target.app/files/zjdroid/ ./ jadx-gui dumped_dex/classes*.dex
二.4 ZjDroid 核心 Hook 代码逻辑(简化版) public class MainHook implements IXposedHookLoadPackage { @Override public void handleLoadPackage (LoadPackageParam lpparam) { XposedHelpers.findAndHookConstructor( "dalvik.system.DexFile" , lpparam.classLoader, String.class, new XC_MethodHook () { @Override protected void afterHookedMethod (MethodHookParam param) { String dexPath = (String) param.args[0 ]; try { File dexFile = new File (dexPath); if (dexFile.exists()) { byte [] dexData = readFile(dexFile); saveDexToFile(dexData, "dump_" + dexFile.getName()); XposedBridge.log("[ZjDroid] Dumped: " + dexPath + " (" + dexData.length + " bytes)" ); } } catch (Exception e) { XposedBridge.log("[ZjDroid] Dump failed: " + e.getMessage()); } } } ); XposedHelpers.findAndHookMethod( "dalvik.system.DexFile" , lpparam.classLoader, "loadClass" , String.class, ClassLoader.class, new XC_MethodHook () { @Override protected void beforeHookedMethod (MethodHookParam param) { String className = (String) param.args[0 ]; loadedClasses.add(className); } } ); } }
三、Frida 内存 Dump 技术(现代替代方案) 由于 ZjDroid 已多年未更新(最后支持 Android 4.x-6.x),现代脱壳几乎都使用 Frida。
三.1 方法一:Hook DexFile 构造函数 Java .perform (function ( ) { var DexFile = Java .use ("dalvik.system.DexFile" ); DexFile .$init .overload ('java.lang.String' ).implementation = function (path ) { console .log ("[*] DexFile init(String): " + path); try { var FileInputStream = Java .use ("java.io.FileInputStream" ); var fis = FileInputStream .$new(path); var Channel = Java .use ("java.nio.channels.FileChannel" ); var channel = fis.getChannel (); var ByteBuffer = Java .use ("java.nio.ByteBuffer" ); var size = channel.size (); var buf = ByteBuffer .allocate (parseInt (size)); channel.read (buf); channel.close (); fis.close (); var bytes = buf.array (); send ({type : "dex" , path : path, size : size}, bytes); } catch (e) { console .log ("[-] Failed to read " + path + ": " + e); } return this .$init(path); }; try { DexFile .$init .overload ('java.nio.ByteBuffer' ).implementation = function (buf ) { console .log ("[*] DexFile init(ByteBuffer) - size: " + buf.capacity ()); var position = buf.position (); buf.position (0 ); var bytes = Java .array ('byte' , new Array (buf.capacity ())); buf.get (bytes, 0 , buf.capacity ()); buf.position (position); send ({type : "dex" , path : "memory_dex" , size : buf.capacity ()}, bytes); return this .$init(buf); }; } catch (e) { console .log ("[-] ByteBuffer overload not available: " + e); } });
三.2 方法二:枚举已加载的 ClassLoader 获取 DEX Java .perform (function ( ) { var PathClassLoader = Java .use ("dalvik.system.PathClassLoader" ); var DexFile = Java .use ("dalvik.system.DexFile" ); Java .enumerateLoadedClasses ({ onMatch : function (className ) { classList.push (className); }, onComplete : function ( ) { console .log ("[*] Total loaded classes: " + classList.length ); } }); });
三.3 方法三:通过 Memory.scan 搜索 DEX 魔数 内存扫描是最底层的 dump 方法,不依赖任何 Java 层 API,因此最难被壳检测和对抗:
var DEX_MAGIC = "64 65 78 0a 30 33 35" ; function scanAndDumpDex ( ) { var found = 0 ; Process .enumerateRanges ('r--' ).forEach (function (range ) { if (range.size < 0x70 ) return ; try { var results = Memory .scanSync (range.base , range.size , DEX_MAGIC ); results.forEach (function (match ) { console .log ("[*] DEX magic found at: " + match.address ); var fileSize = match.address .add (0x20 ).readU32 (); console .log ("[*] DEX size: " + fileSize + " bytes" ); if (fileSize > 0x70 && fileSize < 100 * 1024 * 1024 ) { var dexData = match.address .readByteArray (fileSize); send ({ type : "dex_memory" , address : match.address .toString (), size : fileSize }, dexData); found++; } }); } catch (e) { } }); console .log ("[*] Total DEX found in memory: " + found); }
三.4 方法四:Hook ART 内部函数(更底层) var openMemoryAddr = Module .findExportByName ( "libart.so" , "_ZN3art7DexFile10OpenMemoryEPKhm" ); if (openMemoryAddr) { Interceptor .attach (openMemoryAddr, { onEnter : function (args ) { this .dexData = args[0 ]; this .dexSize = args[1 ].toInt32 (); console .log ("[OpenMemory] DEX data at: " + this .dexData + ", size: " + this .dexSize ); }, onLeave : function (retval ) { if (this .dexSize > 0 && this .dexSize < 100 * 1024 * 1024 ) { var data = this .dexData .readByteArray (this .dexSize ); send ({type : "dex_art" , size : this .dexSize }, data); } } }); } var openCommonAddr = Module .findExportByName ( "libart.so" , "_ZN3art7DexFile10OpenCommonEPKhmRKNSt3__..." var symbols = Module .enumerateSymbolsSync ("libart.so" );symbols.forEach (function (sym ) { if (sym.name .indexOf ("DexFile" ) >= 0 && sym.name .indexOf ("Open" ) >= 0 ) { console .log ("[*] Found ART symbol: " + sym.name + " at " + sym.address ); } });
三.5 Frida 配合 Python 实现自动化脱壳 """ auto_unpacker.py - 自动化 Frida 脱壳脚本 """ import fridaimport sysimport osimport timePACKAGE_NAME = "com.target.app" OUTPUT_DIR = "./dumped_dex" def on_message (message, data ): if message['type' ] == 'send' : payload = message['payload' ] if payload['type' ] == 'dex_memory' : filepath = os.path.join(OUTPUT_DIR, f"dex_{payload['address' ]} _{payload['size' ]} .dex" ) with open (filepath, 'wb' ) as f: f.write(data) print (f"[+] Saved: {filepath} ({payload['size' ]} bytes)" ) def main (): os.makedirs(OUTPUT_DIR, exist_ok=True ) with open ('frida_dex_dump.js' , 'r' ) as f: script_code = f.read() device = frida.get_usb_device() pid = device.spawn([PACKAGE_NAME]) session = device.attach(pid) script = session.create_script(script_code) script.on('message' , on_message) script.load() device.resume(pid) print (f"[*] Attached to {PACKAGE_NAME} (PID: {pid} )" ) print ("[*] Waiting for DEX dumps... (Press Ctrl+C to stop)" ) try : sys.stdin.read() except KeyboardInterrupt: print ("\n[*] Detaching..." ) session.detach() if __name__ == '__main__' : main()
四、手动脱壳完整流程 针对一个未知壳的通用脱壳流程:
git clone https://github.com/rednaga/APKiD cd APKiDpython apkid.py target.apk apktool d target.apk -o unpacked ls unpacked/lib/armeabi-v7a/grep -A5 "application" unpacked/AndroidManifest.xml | grep "android:name" adb install target.apk adb shell am start -n com.target.app/.MainActivity frida -U com.target.app %load frida_dex_dump.js frida -U -l frida_dex_dump.js com.target.app frida -U -f com.target.app -l frida_dex_dump.js --no-pause adb pull /sdcard/dumped_dex/ ./ jadx-gui dumped_dex/*.dex
五、对抗抽取型加固 抽取型加固(如 360 的 VMP)不一次性解密整个 DEX,而是按需解密 每个方法的字节码。
五.1 抽取型加固的工作原理 抽取前的 DEX: class Foo { void method1() { 0x0000: const/4 v0, 0x1 ← 正常指令 0x0002: invoke-static {v0}, ... 0x0006: return-void } void method2() { 0x0000: const-string v0, "bar" ← 正常指令 0x0004: return-object v0 } } 抽取后的 DEX(打包在 APK 中): class Foo { void method1() { 0x0000: invoke-static {}, LStubShell;->decryptMethod(I) ← 跳转到壳 0x0006: return-void } void method2() { 0x0000: invoke-static {}, LStubShell;->decryptMethod(I) ← 跳转到壳 0x0004: return-object v0 } } 运行时 method1() 第一次被调用: 1. decryptMethod(method_id) 从加密数据中取出真实指令 2. 将真实指令回写到 method1() 的 code_item 中 3. 跳转到 method1() 的开头重新执行(此时指令已恢复)
五.2 对抗抽取型加固的 Frida 脚本 Java .perform (function ( ) { var classList = []; var targetClasses = []; Java .enumerateLoadedClasses ({ onMatch : function (className ) { if (!className.startsWith ("android." ) && !className.startsWith ("java." ) && !className.startsWith ("javax." ) && !className.startsWith ("dalvik." ) && !className.startsWith ("com.qihoo" ) && !className.startsWith ("com.secneo" )) { classList.push (className); } }, onComplete : function ( ) { console .log ("[*] Application classes: " + classList.length ); classList.forEach (function (className ) { try { var clazz = Java .use (className); var methods = clazz.class .getDeclaredMethods (); methods.forEach (function (method ) { try { method.setAccessible (true ); var paramTypes = method.getParameterTypes (); var args = []; for (var i = 0 ; i < paramTypes.length ; i++) { args.push (getDefaultValue (paramTypes[i])); } if (java.lang .reflect .Modifier .isStatic ( method.getModifiers ())) { method.invoke (null , args); } else { } } catch (e) { } }); } catch (e) { } }); console .log ("[*] Method invocation complete" ); console .log ("[*] Now run Memory.scan to dump recovered DEX code" ); } }); function getDefaultValue (type ) { if (type.isPrimitive ()) { if (type.getName () == "boolean" ) return Java .use ("java.lang.Boolean" ).FALSE .value ; if (type.getName () == "int" ) return 0 ; if (type.getName () == "long" ) return Java .use ("java.lang.Long" ).valueOf (0 ); if (type.getName () == "float" ) return 0.0 ; if (type.getName () == "double" ) return 0.0 ; if (type.getName () == "short" ) return 0 ; if (type.getName () == "byte" ) return 0 ; if (type.getName () == "char" ) return '