标签:#AndroidReverse #Frida #脱壳 #MobileSecurity #Hook #DEX
⚠️ 免责声明:本文仅供技术研究与安全防御教学使用。请勿将相关技术用于非法破解、制作外挂或破坏商业软件,否则后果自负。
📉 前言:脱壳的核心原理——“落地为安”
静态分析(Static Analysis)面对加固 APP 是无力的,因为 DEX 是加密存储的。
但动态运行(Dynamic Runtime)不会撒谎。
当 APP 启动时,加固壳的逻辑通常如下:
- 加载壳的 SO:通过
System.loadLibrary加载壳的 Native 代码。 - 解密 DEX:在 Native 层解密出原始 DEX 数据。
- 加载 DEX:调用系统函数(如
OpenCommon,OpenMemory,DefineClass)将 DEX 放入内存,准备执行。
我们的战术很简单:守株待兔。
我们不需要知道它怎么解密的,我们只需要 Hook 住加载 DEX的那个系统函数,把它的参数(DEX 内存地址)拿出来,写入文件。
脱壳攻击路径 (Mermaid):
🛠️ 一、 寻找切入点:为什么是 dlopen?
在 Android 中,加载动态链接库(.so)的底层核心函数是dlopen(或android_dlopen_ext)。
加固壳通常会在JNI_OnLoad或.init_array中尽早执行解密逻辑。
如果我们直接 Hooklibart.so里的OpenMemory,可能会因为libart.so还没加载或者是壳还没跑起来而失败。
Hookdlopen的目的是为了寻找“时机”:
- 确保
libart.so已经加载,所有的 ART 运行时函数地址都能找到了。 - 或者监听壳自己的 SO (
libjiagu.so,libbangcle.so) 何时加载,以此作为开始 Dump 的信号。
💻 二、 实战脚本:Frida 核心代码
我们将编写一个 TypeScript/JavaScript 脚本。
1. 监听 SO 加载 (The Trigger)
首先,我们需要拦截android_dlopen_ext(Android 7.0+ 常用)来感知库的加载。
// hook_dlopen.jsfunctionhook_dlopen(){// 适配不同 Android 版本的 dlopen 函数constdlopen=Module.findExportByName(null,"android_dlopen_ext");if(dlopen){Interceptor.attach(dlopen,{onEnter:function(args){// args[0] 是 so 文件的路径this.path=args[0].readCString();},onLeave:function(retval){if(this.path&&this.path.indexOf("libart.so")>=0){console.log("[+] libart.so loaded! Ready to hook ART functions.");hook_art();// libart 加载了,开始 Hook 核心函数}}});}}2. 核心 Hook:OpenMemory (The Dump)
在libart.so中,加载 DEX 的关键函数通常是OpenMemory或OpenCommon。不同的 Android 版本符号名不同(C++ Name Mangling),建议使用Module.enumerateSymbols模糊匹配。
functionhook_art(){constlibart=Process.findModuleByName("libart.so");if(!libart)return;// 遍历符号,寻找包含 OpenMemory 的函数constsymbols=libart.enumerateSymbols();letopenMemoryAddr=null;for(leti=0;i<symbols.length;i++){constname=symbols[i].name;// 模糊匹配 OpenMemory,这是加载内存 DEX 的常用函数if(name.indexOf("OpenMemory")>=0&&name.indexOf("DexFile")>=0){openMemoryAddr=symbols[i].address;console.log("[*] Found OpenMemory: "+name);break;}}if(openMemoryAddr){Interceptor.attach(openMemoryAddr,{onEnter:function(args){// OpenMemory 的参数通常是 (base, size, location, check_checksum, ...)// args[0] 是 DEX 在内存中的起始地址 (const uint8_t* base)// args[1] 是 DEX 的大小 (size_t size) - 有些版本顺序不同,需结合源码确认// ⚠️ 注意:不同 Android 版本参数位置可能不同,这里以常见情况为例// 很多时候 args[0] 是 base 地址constdexBase=args[0];// 简单的 Magic Header 检查 ('dex\n035')// 0x64 0x65 0x78 0x0Aconstmagic=dexBase.readByteArray(4);// 这里应该转换并检查 magic 是否正确console.log("[*] OpenMemory called. Base: "+dexBase);// 在这里我们可能无法直接获取 size,或者 size 很大// 策略:先读取 Header 中的 filesize 字段// DEX Header + 32 字节处是 file_size (4 bytes)constfileSize=dexBase.add(32).readU32();console.log("[*] Dex Size from Header: "+fileSize);// 执行 Dumpdump_dex(dexBase,fileSize);}});}}3. 写入文件 (The Output)
将内存数据保存到/data/data/包名/下。
functiondump_dex(base,size){constfilename="/data/data/com.example.targetapp/"+size+".dex";constfile=newFile(filename,"wb");if(file){// 从内存读取字节流constbuffer=base.readByteArray(size);file.write(buffer);file.flush();file.close();console.log("[+] DEX Dumped successfully: "+filename);}}// 启动脚本setImmediate(hook_dlopen);🔎 三、 运行与验证
- 启动 Frida Server: 在手机端运行
frida-server。 - 执行攻击:
frida -U -f com.example.targetapp -l hook_dlopen.js --no-pause- 观察日志: 当 APP 启动时,你会看到控制台疯狂输出。
[+] libart.so loaded![*] Found OpenMemory...[+] DEX Dumped successfully...
- 提取文件:
adb pull /data/data/com.example.targetapp/123456.dex.⚠️ 四、 避坑指南:壳的对抗手段
现在的壳也没那么傻,它们有反制措施:
- DEX 头部抹除:
壳在加载完 DEX 后,会故意把内存中 DEX 文件的 Header(魔数dex.035)抹成 00,防止你通过搜索 Header 特征来 Dump。
对策:Dump 出来后,用 010 Editor 手动修复头部,把64 65 78 0A填回去。 - 函数抽取 (Code Item Extraction):
你 Dump 出来的 DEX,里面的 Method 指令全是空的(nop),或者是一个无效的跳转。真正的指令在执行时才通过OnMethodEnter动态恢复。
对策:这是高阶对抗,需要使用Frida-DexDump(基于内存搜索) 或者定制化的 ART 虚拟机(FART)来进行深度脱壳。 - Anti-Frida:
壳会检测 Frida 的端口、线程名、maps 文件。
对策:使用魔改版 Frida(去特征),或者使用StrongR-Frida。
🎯 总结
通过 Hookdlopen和OpenMemory,我们绕过了复杂的解密算法,直接在终点站截获了 DEX。
这就是**“降维打击”**。
不管加密算法多牛,数据终究是要给 CPU 跑的。只要它敢在内存里露头,Frida 就能把它揪出来。
Next Step:
你 Dump 出来的 DEX 很可能是“函数抽取”后的残缺版。下一步,你需要学习如何使用FART (Fast Android Runtime)原理,通过主动调用所有函数,强迫壳把指令还原回内存,从而抓取完整的代码。