资讯详情

Frida动态插桩实战:盯住dlopen从内存中dump DEX实现脱壳

📅 2026/10/19 6:05:14 | 华诺云谱 👁 阅读
Frida动态插桩实战:盯住dlopen从内存中dump DEX实现脱壳
看到这个标题估计不少搞移动安全和逆向分析的朋友都有同感现在做脱壳最常用的路子已经不再是静态硬刚而是拿 Frida 这类动态插桩工具盯着dlopen等关键加载入口在 DEX 字节于内存里“短暂现身”的时间窗内把它捞出来。这套思路听起来不复杂实际落地时坑却不少时机差一点就错过、搜索范围不对直接卡死、dump 出来的文件校验值不对又是一顿折腾每一步都能让人卡上半天。这篇文章我把完整链路掰开揉碎讲一遍加固到底做了什么、为什么dlopen是关键 hook 点、内存搜索怎么搜不卡、dump 之后怎么修复才能用再附上我踩过的一堆坑。适合刚接触 Android 逆向、想搞懂脱壳原理的测试同学也适合被商业加固折腾过的开发和安全研究员参考。1. 先说清楚加固到底做了什么我们为什么要从内存里捞 DEX1.1 加固流程的基本套路现在市面上主流的加固方案本质上都围绕一个目标把代码藏起来。它们会把原本的classes.dex加密或切碎替换成一个很小的壳 dex真正的业务 DEX 要么放在 assets 目录里加密存储要么藏在某个 so 里要么分段散落在不同位置。App 启动时壳的 Application一般叫 StubApp 或者类似代号先跑起来然后由壳 so 在 native 层完成解密、还原 dex 字节再通过自定义类加载器把还原出来的 dex 交给 ART 虚拟机加载。这条链路里最关键的特点就是无论壳怎么藏、怎么加密、怎么做指令抽取最终都必须把“完整可解析的 dex 字节”交给虚拟机。只要这一步存在就有一个相对通用的攻击窗口。你不需要去逆向壳的加密算法也不需要分析它的完整 so 逻辑只要在它解密完成、类还没完全加载完成的时间点把内存中那段 dex 原始字节拷贝出来就行。这就是内存脱壳能成立的根本原因。有个细节值得注意很多壳会故意把 dex 还原过程拉长中间穿插各种花指令、内存擦除、CRC 校验目的就是让这个窗口变得不稳定。但窗口只能变小不能消失——ART 要解析类就必然存在一段可被 DEX 解析器读取的连续字节。1.2 为什么“等 ClassLoader”不如“盯 dlopen”靠谱网上很多入门教程喜欢教你 hookDexClassLoader或者BaseDexClassLoader这种 Java 层入口然后拿 path 参数去读文件。这个思路在早期对付简单壳时还行现在基本行不通。一是很多加固直接自定义 ClassLoaderJava 层的构建过程被搬到 native 层Java Hook 根本看不到关键参数二是就算你 hook 到了传入的 path 可能指向加密后的文件对象读出来也不是可用的 dex。所以我个人更推荐把关注点往下沉一层盯着dlopen和它的变体android_dlopen_ext。理由很简单加固解密逻辑几乎都放在 so 里so 必须通过动态加载的方式被系统拉起来。dlopen是绕不过去的“大门”只要它被调用说明一个新的 so 正要进入进程。壳的init_array或JNI_OnLoad通常就挂在它返回前后执行解密逻辑。我们不需要知道解密细节只需要在窗口之后去进程的可读内存里搜索 DEX 头部特征再把匹配内存块 dump 出来。这其实是一个“守门人”思路盯住最底层、最无法替换的入口比跟着具体厂商的实现跑要省力得多。你把一堆壳 A、壳 B、壳 C 的处理逻辑都逆一遍成本巨大但你盯着dlopen的加载时机写一套脚本能覆盖绝大多数方案。1.3 动手前需要准备好的基础清单开始之前先把工具备齐一台可调试的 Android 设备建议 Android 8~11 系统内存 2GB 以上有 root 最好没有 root 也可以考虑 frida-gadget 方案与设备架构匹配的 frida-serverarm32 和 arm64 别下错电脑端 Python 3 环境安装 frida-toolsadb 能连上设备adb devices能看到目标一个十六进制编辑器或者 010 Editor用来事后检查 DEX 文件头目标 App 的调试包最好先静态看一眼它的加固方式。这些准备工作中最容易翻车的就是版本不匹配。frida-server 和设备 CPU 架构不匹配会直接启动失败电脑端 frida 工具和手机端 frida-server 版本差太多也会握手失败。我的习惯是统一用同一个大版本比如手机端 frida-server 14.2.18电脑端 pip 安装的 frida 也选 14.2.18 附近版本宁可比最新版保守一点。2. 环境搭建与 Frida 脚本骨架2.1 设备、frida-server 与电脑端版本怎么配先把 frida-server 推到设备上。常规操作是adb push frida-server /data/local/tmp/ adb shell chmod 755 /data/local/tmp/frida-server adb shell /data/local/tmp/frida-server 注意chmod别忘了否则会提示权限不足。启动成功后电脑端执行frida-ps -U如果能看到设备进程列表说明连接正常。如果这里就报错大概率是版本不匹配或者端口被占用先adb shell ps -A | grep frida看看进程在不在不行就杀掉重来。这里有一个很多人忽略的点frida-server 需要 root 权限才能 attach 到绝大多数目标进程所以在测试机上我建议直接刷一个可 root 的系统或者用模拟器里带 root 的镜像能省掉后面很多权限问题。没有 root 的话要看目标 App 的android:debuggable属性配合run-as或者 gadget 注入但流程会复杂不少。2.2 脚本骨架与常用代码片段Frida 脚本本质上跑在目标进程里的 JavaScript 运行时中可以同时操作 Java 层和 native 层。一个最基础的骨架长这样use strict; function main() { console.log([] script loaded); // Java 层操作 Java.perform(function () { console.log([] Java available); // 在这里 hook Java 方法 }); // native 层操作 // 在这里 Interceptor.attach / Module.findExportByName } setImmediate(main);写脚本时我习惯用setImmediate包一层避免脚本加载时机不对导致 Java 环境还没准备好。Java.perform是必须的Java 层任何操作都要在里面执行否则会报无法找到 Java 类之类的错误。2.3 先验证 Frida 能不能 attach 上目标进程验证连接最直接的方式是 spawn 模式启动目标也就是冷启动注入。这样能在 App 自己的代码跑起来之前就注入进去优势是很早就能介入适合加固场景。命令长这样frida -U -f com.example.target -l hook.js如果 attach 不上先在frida-ps -U里确认进程名是否存在。如果进程存在但 attach 失败常见原因是加固自己做了 Frida 检测或者进程有反调试。这种情况我会先换 spawn 模式因为很多检测是在 Application 启动之后才挂上的冷启动注入有机会抢先一步。3. 盯住 dlopen定位 DEX 解密落地的时间窗3.1 dlopen 在加壳链路里的位置要想理解dlopen为什么是关键就得先看 so 从加载到执行的大致链路。dlopen负责把 so 文件映射进内存并触发动态链接器解析依赖、执行init_array、完成 so 的初始化。很多加固方案会把解密逻辑放在JNI_OnLoad或者init_array里因为这两个时机避开了 Java 层的静态调用不容易被普通分析工具观察。但换个角度想不管解密逻辑放在哪它都要在某个 so 被加载之后才有机会跑。当你通过Interceptor.attach在dlopen上挂了一个回调并且在该 so 加载完成后的onLeave阶段去扫描内存理论上就能覆盖init_array已经执行、JNI_OnLoad正在执行或者刚执行完的状态。实际测试中有些壳在JNI_OnLoad里才真正解密 dex所以onLeave之后最好稍等几十到几百毫秒再扫描给后续逻辑留出时间。别小看这个延时很多人就是在这里踩坑扫太早啥都搜不到扫太晚 dex 已经被虚拟机释放或者加密回收到某个临时区域了。3.2 同时拦截 dlopen 与 android_dlopen_extAndroid 系统里除了标准dlopen还有android_dlopen_ext这个扩展函数支持更多 flag 和 namespace 参数壳 so 经常通过它来加载。写脚本时最好两个都一起 hook保险一些。核心代码如下function hookDlopen() { const dlopen Module.findExportByName(null, dlopen); const dlopenExt Module.findExportByName(null, android_dlopen_ext); function patchDlopen(addr, name) { Interceptor.attach(addr, { onEnter(args) { if (args[0].isNull()) { this.path null; } else { this.path args[0].readCString(); } }, onLeave() { if (this.path this.path.indexOf(libprotect) ! -1) { console.log([${name}] loaded: ${this.path}); setTimeout(scanAllMaps, 150); } } }); } if (dlopen) patchDlopen(dlopen, dlopen); if (dlopenExt) patchDlopen(dlopenExt, android_dlopen_ext); }这里有个小技巧onEnter里把路径保存到this.path因为onLeave里再去readCString往往已经读不到完整内容了。另外args[0]可能为空要先isNull()判断不然脚本容易崩。setTimeout是 Frida 脚本环境支持的全局函数延迟 150ms 是经验值根据目标 so 的大小和解密复杂度可以调有些壳要等 500ms 以上才能解密完。3.3 枚举模块缩小内存搜索范围wait 扫描之前还有一个关键步骤缩小范围。直接对整个进程地址空间做全量Memory.scan即使能找到 dex速度也慢得让人崩溃而且很容易触发 Frida 自身的异常退出。我一般会先枚举所有可读内存区域然后过滤掉系统区域的 so、明显的无关内存块再对剩下的区域做搜索。代码如下function enumerateTargetRanges() { const ranges Process.enumerateRanges(r--); const targets []; for (let range of ranges) { if (!range.file) { // 匿名映射区域很可能是堆上分配的 dex targets.push(range); continue; } const path range.file.path; if (path (path.startsWith(/system/) || path.startsWith(/apex/))) { continue; } targets.push(range); } return targets; }Process.enumerateRanges(r--)拿到的是可读区域file为 null 代表匿名映射这类区域存放动态分配数据的概率很大。过滤掉/system/和/apex/能去掉绝大部分系统库让搜索量级瞬间降下来。实际扫描时我还习惯限制单块区域大小比如超过几百 MB 的跳过防止脚本卡死。4. 内存搜索与 DEX dump 完整实现4.1 DEX 文件头特征快速识别DEX 文件最开始的 8 个字节是魔数常见格式是64 65 78 0a 30 33 35 00对应 ASCII 就是dex\n035\0。也有少部分 DEX 用036、037、038、039等版本号所以更稳妥的匹配策略是先用64 65 78 0a定位再校验第 5 字节是不是0x30、第 8 字节是不是0x00。关于文件头还有一个经常被忽略的细节如果内存里的 dex 头部被壳篡改比如魔数被临时改成别的值等虚拟机加载完再改回来直接搜标准魔数会漏掉。这种情况我会额外搜索64 65 78 0a这个前缀虽然可能误报但至少能发现头部被部分修改的 DEX。4.2 搜索策略按区段扫描避免全量扫描卡死搜索时我习惯用Memory.scan的onMatch回调来边扫描边处理而不是把所有匹配结果收集完再 dump因为内存里偶尔会有假匹配数量多了容易爆内存。function findDexInRange(base, size) { const pattern 64 65 78 0a 30 33 35 00; let foundCount 0; Memory.scan(base, size, pattern, { onMatch(addr, matchSize) { foundCount; console.log([scan] dex header at ${addr}, matchSize${matchSize}); dumpDex(addr); }, onComplete() { if (foundCount 0) { console.log([scan] done, found ${foundCount} in ${base}); } } }); return foundCount; }这里注意Memory.scan是异步的onComplete才会在扫描结束时触发所以不要在scanAllMaps里直接同步统计结果。还有一点base要尽量按页对齐因为 ARM 架构下内存映射的粒度是 4KB如果传入的地址不对齐扫描可能会漏掉边界区域。4.3 dump 下来的字节怎么保存最稳妥匹配到 DEX 头部后需要读取file_size字段它位于 DEX header 的 0x20 偏移处小端 4 字节。读取完整 dex 字节后我推荐通过send把二进制数据直接回传电脑端避免在设备上写文件时因权限或者路径问题失败。设备端脚本这样处理function dumpDex(addr) { const header Memory.readByteArray(addr, 0x70); const u8 new Uint8Array(header); // 小端解析 file_size const fileSize u8[0x20] | (u8[0x21] 8) | (u8[0x22] 16) | (u8[0x23] 24); if (fileSize 0x70 || fileSize 200 * 1024 * 1024) { console.log([dump] weird fileSize ${fileSize}, skip); return; } const dexBytes Memory.readByteArray(addr, fileSize); send({ type: dex, addr: addr.toString(), size: fileSize }, dexBytes); }电脑端 Python 保存import os import frida out_dir dump os.makedirs(out_dir, exist_okTrue) counter 0 def on_message(message, data): global counter if message[type] send: payload message[payload] if payload and payload.get(type) dex: path os.path.join(out_dir, fdex_{counter:03d}_{payload[size]}.dex) with open(path, wb) as f: f.write(data) print(fsaved {path}) counter 1 elif message[type] error: print([error], message.get(stack)) device frida.get_usb_device() pid device.spawn([com.example.target]) session device.attach(pid) with open(hook.js, r, encodingutf-8) as f: script session.create_script(f.read()) script.on(message, on_message) script.load() device.resume(pid) input(press enter to stop...)send的第二个参数是 ArrayBuffer 类型的二进制数据在 Python 端对应on_message的data参数。这样 dex 文件会直接落在电脑的dump目录里方便接下来用工具分析。4.4 dump 之后必须处理的文件校验和修复这一步是最容易被新手忽视的。从内存里扒出来的 dex即使头部完整也不一定能直接丢进 baksmali 或者 jadx。原因很简单DEX header 里有checksum和signature两个字段内存中的 dex 往往没有经过正确的校验值更新或者壳在还原时故意填了假的校验值。Android 虚拟机加载时有时候不在乎但离线解析工具通常会严格检查。DEX 的校验规则是signature字段位于 0x0C长度 20 字节内容是 SHA-1 哈希覆盖范围是从 0x20 到文件末尾checksum字段位于 0x08长度 4 字节内容是 Adler-32 校验值覆盖范围是从 0x0C 到文件末尾。修复脚本如下import hashlib import struct import zlib def fix_dex_header(data): data bytearray(data) if len(data) 0x70: print(dex too small, ignore) return bytes(data) # 先清空 signature 和 checksum data[0x08:0x0C] b\x00 * 4 data[0x0C:0x20] b\x00 * 20 # signature 从 0x20 开始到文件末尾 sha1 hashlib.sha1(data[0x20:]).digest() data[0x0C:0x20] sha1 # checksum 从 0x0C 开始到文件末尾 checksum zlib.adler32(data[0x0C:]) data[0x08:0x0C] struct.pack(I, checksum) return bytes(data)修复完再用 baksmali 或者 jadx 打开验证如果还报错就检查file_size字段是不是和实际长度一致以及header_size是否为0x70。有些壳会在 header 里做手脚比如file_size故意写小导致工具读取不完整。这时候直接拿实际len(data)回填 0x20 偏移处的字段再重新算校验值基本就能解决。5. 坑位实录我在实际脱壳时踩过的雷5.1 明明加载了 DEX却搜不到 dex\n035 特征这种情况碰到过好几次原因主要有两类。第一类壳把标准魔数改成了自定义字节需要在脱壳前先静态分析一下壳的 so看它解密完以后是不是做了魔数还原的步骤如果是在还原后再扫如果不是就扩大搜索模式搜64 65 78 0a前 4 个字节匹配到后再人工判断。第二类更隐蔽加固用了InMemoryDexClassLoader底层通过art::DexFile::OpenMemory直接从 bytebuffer 构造 DexFiledex 字节可能只在内存中极短时间存在等你延时 150ms 再扫那段内存已经被释放或者覆盖了。对付这种壳最直接的思路是从libart.so里找到OpenMemory之类的符号直接 hook 它的 buffer 参数在解密后的第一现场 dump。符号名版本差异大可以用Module.enumerateSymbols模糊搜索包含OpenMemory的导出符号。5.2 dump 出来一堆 DEX哪个才是真正的业务代码用内存搜索的方式 dump经常会在堆里匹配到很多个 dex 头。有些是系统框架的 dex有些是壳自己加载的辅助 dex真正包含业务逻辑的 dex 不一定是最先被搜索到的那个。我一般用两个特征来判断先用 010 Editor 的 DEX 模板看class_defs_size、string_ids_size字段业务 dex 的class_defs_size通常从几十到几千不等系统 dex 会更大再配合自己的分析需求比如看包里是否包含目标应用的主包名路径。另一个办法是在 dump 的瞬间用Thread.backtrace(this.context, Backtracer.FUZZY)打印调用栈凡是调用栈经过DexClassLoader、InMemoryDexClassLoader附近的优先关注。5.3 遇到主动反调试、反 Frida 怎么办现在不少加固都会检测 Frida 特征常见手段包括扫描/proc/self/maps、检测默认端口 27042、检测特定线程名、以及主动调用pthread_create做定时器轮询。如果脚本一 attach 上去进程就直接退出先考虑是不是触发了这类检测。基础绕过方式有几个方向给 frida-server 改名比如改成app_process这种看不出特征的名字换用 frida-gadget 方案把 so 注入到 App 进程中而不是依赖 frida-server 的独立进程这样很多基于进程名的检测会失效在 Frida 脚本里 hookpthread_create在线程刚创建时把线程名改成无害的名字能骗过一部分轮询线程的检测尽量从 spawn 模式启动在加固检测逻辑还没跑起来之前就完成注入。这些手段不是万能的每个壳的检测逻辑不一样需要针对实际情况组合使用。我的经验是先不要一上来就堆一堆反检测代码先确定触发点在哪根据崩溃日志和停止位置反推检测逻辑针对性绕过。5.4 函数抽取类加固dump 完了还得补指令这里要特别说明内存 dump 并不是所有场景的终点。如果目标是函数抽取类加固它会在 App 安装包里把一个 dex 的code_item整体掏空只留下空壳真正的指令在运行时由壳的 so 一个个填回去。这种壳即便你完整 dump 出 dex 文件也会发现所有方法体都是空的baksmali 反编译出来只剩一堆return-void。对付抽取壳主流做法是在方法首次执行、指令填充完成后在内存里把对应方法的指令抄回来patch 到 dump 的 dex 文件里。简单说就是hookArtMethod::Invoke或者在 dex2oat 编译阶段接管等方法对应的 code_item 被还原后读取完整指令并写回文件。这是一套单独的技术栈不是简单 dump 能解决的。如果你遇到的是这类加固只做dlopenhook 肯定不够需要再往下深挖。5.5 一个建议的完整流程少走弯路最后把我自己常用的一套流程整理出来照着这个顺序推进能省去不少反复试错的时间步骤操作目的1静态分析目标 App确认加固厂商和大概的壳类型决定用纯 dlopen hook 还是需要更深层 hook2准备环境启动 frida-server确认能 spawn 启动目标排除环境问题3挂载 hook dlopen 和 android_dlopen_ext 的脚本观察 so 加载路径找解密时机4命中壳 so 后延迟 100~500ms 扫描可读内存区域在 dex 存活窗口内捞到完整 dex5dump 多个候选 dex 并回传电脑保存证据便于离线分析6检查验证 dex 头部、文件大小、校验值确认文件可被解析工具读取7用 jadx/baksmali 开 dump 结果判断有无抽取决定是否需要进入“补指令”阶段这套流程对大多数以“加载 so 解密 dex 自定义 ClassLoader”为基本结构的加固方案都能覆盖。不同版本的 Android 系统、不同风格的加固实现主要差别在于时机和细微的检测手段但大方向是通用的。我个人在实际操作中最深的一点体会是动态分析拼的不是工具多猛而是对时机和内存布局的理解。早年我也整天找脱壳机、想着一键搞定后来慢慢搞懂了dlopen到解密再到类加载这条链再回头看各家方案思路就清楚多了。脱壳只是第一步真正花时间的是脱壳之后的分析比如方法还原、逻辑梳理、算法定位那才是消耗精力的主战场。建议你在自己可控的样本上多试几种 dump 时机记录每种加固的特征指纹慢慢沉淀一套自己的脱壳工具箱。最后再提醒一句所有分析请确保样本来源合法、范围授权别把功夫用错了地方。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑