1. 脱壳不是“解密”而是绕过运行时保护的动态博弈你拿到一个APKjadx-gui打开一片空白apktool d反编译后smali目录下只有几个壳的启动类classes.dex体积小得反常——这基本可以断定它被加了壳。而“使用 fridadexdump 对 APK 脱壳”说的不是用某个工具一键点开就吐出原始classes.dex而是在应用真正加载、解密、反射生成真实 DEX 的那一瞬间用 Frida 注入进程捕获内存中已解密但尚未执行的字节码并用 dexdump 工具将其序列化为可静态分析的文件。这个过程本质上是一场时间差博弈壳厂商在Application.attach()或System.loadLibrary()后立即擦除内存中的明文 DEX而我们要做的就是在它擦除前把那块内存快照下来。核心关键词——Frida、dexdump、APK 脱壳、内存 dump、DEX 加载时机、Android 壳检测——全部指向一个现实场景你不是在做 CTF 静态逆向而是在真实业务中分析竞品 SDK 的行为逻辑、排查第三方库的隐私采集路径或是验证自家 App 壳的防护强度。这类工作不追求“全自动”但要求每一步都可解释、可复现、可调试。我做过不下 30 个主流加固厂商包括某信、某游、某金融系的脱壳实战发现一个铁律所有壳的“解密-加载-擦除”三阶段流程高度一致差异只在于触发时机和内存操作手法而 Frida 的 hook 能力恰好卡在“加载”与“擦除”的毫秒级窗口之间。所以本篇不讲“通用脱壳脚本”而是带你从零构建一套可调试、可迁移、可定位失败原因的脱壳工作流——它不依赖特定壳名不迷信某版 Frida也不假设你已 root 设备虽然绝大多数场景仍需 root。接下来的内容每一行命令、每一个 hook 点、每一次内存扫描都是我在凌晨三点反复重启模拟器、比对 17 个不同 Android 版本日志后确认的最小可行路径。2. Frida 的本质是“运行时手术刀”不是“万能钥匙”很多人一上来就frida -U -f com.xxx.xxx -l hook.js --no-pause结果脚本跑完dexdump没抓到任何东西甚至 Frida 自己都报Script crashed: Error: unable to find function。这不是脚本写错了而是没理解 Frida 在整个脱壳链路里的真实角色它不是直接“调用 dexdump”而是在目标进程的内存空间里精准定位到 DEX 文件头magic numberdex\n035\0所在的地址然后把这个地址长度传给dexdump命令去解析。换句话说Frida 是“找人”dexdump是“验尸”两者分工明确缺一不可。2.1 为什么必须用 Frida——静态分析的三大死穴先说清楚 Frida 不可替代的原因。有人问“我直接用adb shell进去cat /proc/self/maps看内存布局再dd if/proc/self/mem ofdump.bin skipxxx bs1 countyyy不行吗”理论上可行但实操中会撞上三堵墙第一堵墙权限隔离。从 Android 8.0Oreo开始/proc/pid/mem默认仅对root和同 UID 进程可读。普通 adb shell 用户即使有adb root权限在非 debuggable 应用里也常被 SELinux 策略拦截。我试过在 Pixel 4aAndroid 12上对某电商 App 执行dd返回Permission denied而 Frida 的Process.enumerateModules()却能稳定列出所有加载模块——因为 Frida 是以ptrace方式 attach 进程走的是内核级调试通道绕过了文件系统权限层。第二堵墙内存碎片化。壳加载真实 DEX 时极少整块分配连续内存。更常见的是解密后拆成 3~5 段分别写入mmap分配的匿名页再用dlopendlsym动态链接。/proc/pid/maps里可能有十几段r-xp区域每段几百 KB但真实 DEX 头只藏在其中一段的中间位置。靠grep扫dex\n035\0效率极低且容易漏掉被混淆的 magic比如某壳会把035改成036再运行时修复。而 Frida 的Memory.scan()接口支持正则匹配、偏移跳转、多线程并发扫描实测在 2GB 内存里搜一个 8 字节 pattern平均耗时 1.2 秒比 shell 脚本快 8 倍以上。第三堵墙时机不可控。dd是一次性快照你永远不知道它拍下的是解密前的密文、解密中的乱序数据还是擦除后的零填充。而 Frida 的Java.perform()和Interceptor.attach()允许你在 Java 层DexClassLoader.loadClass()调用前、Native 层dvmDexFileOpenPartial()返回后、甚至mprotect()修改内存属性的瞬间插入 hook——这才是“卡在毫秒窗口”的技术基础。提示Frida 的Process.enumerateRanges(r--)返回的内存区域其protection字段必须包含r可读但真实 DEX 数据往往存在于rwx可读可写可执行区域——因为壳需要在运行时 patch 方法体。所以扫描范围不能只盯r--必须覆盖rwx和rw-。2.2 为什么必须用 dexdump——而不是 objdump 或 readelf另一个常见误区是既然都 dump 内存了为啥不用更通用的objdump -d反汇编答案很直接objdump解析的是 ELF 格式而 Android 的 DEX 是完全独立的字节码格式二者 header 结构、section 划分、符号表存储方式毫无兼容性。readelf同理它只认.so文件的 ELF header魔数\x7fELF而 DEX 的魔数是dex\n035\0ASCII 编码共 8 字节。强行用objdump解析 DEX 内存块输出全是disassembly of section .data这样的无效信息根本无法还原类结构、方法签名、字符串池。dexdump是 Android SDK 自带的官方工具位于$ANDROID_HOME/platform-tools/dexdump它专为 DEX 格式设计能正确解析header_item中的file_size、header_size、endian_tagstring_ids、type_ids、proto_ids等索引区class_def_item列表及每个类的class_data_item含字段、方法定义code_item中的 Dalvik 字节码指令流.method块更重要的是dexdump -ddisassemble模式输出的是人类可读的 smali-like 伪代码比如# virtual methods .method public final synthetic access$000(Lcom/example/MainActivity;)V .registers 2 .parameter this .parameter x0 .prologue .line 42 invoke-direct {p0, p1}, Lcom/example/MainActivity;-doSomething(Lcom/example/MainActivity;)V return-void .end method这比jadx的 Java 反编译更贴近原始逻辑尤其适合分析被混淆的控制流如goto嵌套、异常处理块重排。注意dexdump不是万能的。它无法处理被DexClassLoader动态生成的InMemoryDexClassLoader加载的 DEXAndroid 9 引入因为这类 DEX 不写入磁盘且内存布局更碎片化。本篇聚焦传统DexClassLoader场景这是目前 85% 以上加固壳的默认加载方式。2.3 Frida 与 dexdump 的协作模型四步闭环整个脱壳流程不是线性执行而是一个 Frida 主导、dexdump辅助的闭环Hook 触发Frida 注入目标进程在DexClassLoader.init或BaseDexClassLoader.findClass入口处设断点捕获dexPath参数通常是/data/data/com.xxx.xxx/files/xxx.dex或optimizedDirectory缓存目录内存定位若dexPath是临时文件直接cat提取若为内存加载则用Memory.scan()在rwx区域搜索dex\n035\0获取起始地址baseAddr和大小size内存提取调用Memory.readByteArray(baseAddr, size)将整块内存读为Uint8Array通过Java.array(byte, ...)转为 Java 字节数组再用FileOutputStream写入/data/local/tmp/raw.dex格式校验与解析adb shell进入设备执行dexdump -d /data/local/tmp/raw.dex /data/local/tmp/dump.txt检查输出是否含Processing raw.dex...和Class #1等有效字段。若失败说明baseAddr偏移不准或size过小需回退到第 2 步调整扫描策略。这个闭环里Frida 负责“感知”和“搬运”dexdump负责“鉴定”和“翻译”。任何一环断裂整个流程就失效。我见过太多人卡在第 3 步Memory.readByteArray报RangeError: offset is out of bounds其实只是因为baseAddr是0x7f12345000而 Frida 的readByteArray要求地址必须对齐到页面边界4KB需手动向下取整baseAddr ~0xfff。3. 真实脱壳的四类核心 Hook 点与实操细节脱壳成功与否80% 取决于你选对了哪个函数作为 hook 入口。不是所有 Java 方法都适合也不是所有 Native 函数都稳定。我按实战效果排序给出四类最可靠、最易调试的 hook 点并附上每个点的触发逻辑、参数解析技巧和典型失败案例。3.1 最优选择DexClassLoader.init构造函数Java 层这是我的首选 hook 点原因有三触发早在壳完成解密、准备加载真实 DEX 前就被调用此时dexPath参数还完好无损参数直白构造函数接收String dexPath, String optimizedDirectory, String librarySearchPath, ClassLoader parent四个参数dexPath往往就是解密后 DEX 的临时路径如/data/data/com.xxx.xxx/files/123456.dex稳定性高DexClassLoader是 Android 官方类API 稳定从 API 1 到 34 未变无需适配不同 Android 版本。实操步骤如下以 Frida JS 脚本为例Java.perform(function () { var DexClassLoader Java.use(dalvik.system.DexClassLoader); DexClassLoader.$init.overload(java.lang.String, java.lang.String, java.lang.String, java.lang.ClassLoader).implementation function (dexPath, optimizedDirectory, librarySearchPath, parent) { console.log([] DexClassLoader init called with dexPath: dexPath); console.log([] optimizedDirectory: optimizedDirectory); // 关键这里 dexPath 很可能是真实 DEX 路径直接 pull if (dexPath dexPath.indexOf(/data/) 0 dexPath.endsWith(.dex)) { console.log([*] Found potential dex file: dexPath); // 用 adb pull 或 Frida 的 File API 读取 try { var file Java.use(java.io.File).$new(dexPath); var fis Java.use(java.io.FileInputStream).$new(file); var bytes Java.array(byte, new Array(file.length()).fill(0)); fis.read(bytes); fis.close(); // 将字节数组保存到 /data/local/tmp/original.dex var fos Java.use(java.io.FileOutputStream).$new(/data/local/tmp/original.dex); fos.write(bytes); fos.close(); console.log([] Successfully dumped to /data/local/tmp/original.dex); } catch (e) { console.log([-] Failed to read dex file: e); } } // 必须调用原函数否则应用崩溃 return this.$init.call(this, dexPath, optimizedDirectory, librarySearchPath, parent); }; });注意this.$init.call(...)是关键。很多新手删掉这行以为“hook 就是替换”结果应用直接NullPointerException崩溃——因为DexClassLoader的初始化逻辑必须执行否则后续findClass会找不到类加载器上下文。典型失败场景与修复问题dexPath是空字符串或null。原因某些壳如某游 V2使用InMemoryDexClassLoaderAndroid 9不传dexPath而是传byte[] dexBytes。修复追加 hookInMemoryDexClassLoader.init其 overload 为overload([B, java.lang.ClassLoader)直接读取dexBytes参数。3.2 次优选择BaseDexClassLoader.findClassJava 层当DexClassLoader.init没有捕获到有效dexPath时转向findClass。它的优势是只要类被加载就必然经过此函数且第一个参数name类全限定名能帮你确认当前加载的是壳代码还是原始业务代码。Java.perform(function () { var BaseDexClassLoader Java.use(dalvik.system.BaseDexClassLoader); BaseDexClassLoader.findClass.overload(java.lang.String).implementation function (name) { console.log([] findClass called for: name); // 检查是否是业务包名如 com.example.app.MainActivity if (name.startsWith(com.example.)) { console.log([*] Target class detected: name); // 此时 DEX 已加载进内存开始扫描 var ranges Process.enumerateRanges(rwx); console.log([*] Scanning ranges.length rwx memory ranges...); ranges.forEach(function (range) { Memory.scan(range.base, range.size, 64 65 78 0a 30 33 35 00, { // dex\n035\0 in hex onMatch: function (address, size) { console.log([] Found DEX header at: address.toString()); // 读取并保存 var dexBytes Memory.readByteArray(address, 0x10000); // 先读 64KB // 后续用 dexdump 校验 size }, onError: function (reason) { console.log([-] Scan error: reason); } }); }); } return this.findClass.call(this, name); }; });关键技巧Memory.scan的 pattern 必须用十六进制字符串64 65 78 0a 30 33 35 00而非 ASCII 字符串dex\n035\0因为 Frida 的 scan 接口底层调用的是memcmp对\n和\0的转义处理不稳定。实测用 hex 模式成功率提升 92%。3.3 Native 层首选dvmDexFileOpenPartialAndroid 4.4 及以下对于老版本 AndroidKitKat 及以下Native 层的dvmDexFileOpenPartial是黄金 hook 点。它在 Dalvik 虚拟机中负责将内存块解析为DvmDex*结构体参数pDexFile就是指向解密后 DEX 数据的指针。Interceptor.attach(Module.findExportByName(libdvm.so, dvmDexFileOpenPartial), { onEnter: function (args) { console.log([] dvmDexFileOpenPartial called); console.log([*] DEX data pointer: args[0]); console.log([*] Size: args[1]); // args[0] 是 uint8_t*args[1] 是 size_t var dexData args[0]; var size parseInt(args[1]); // 读取前 16 字节确认 magic var magic Memory.readByteArray(dexData, 8); console.log([*] Magic bytes: magic.map(b b.toString(16).padStart(2,0)).join( )); if (magic[0] 0x64 magic[1] 0x65 magic[2] 0x78 magic[3] 0x0a magic[4] 0x30 magic[5] 0x33 magic[6] 0x35 magic[7] 0x00) { console.log([] Valid DEX magic confirmed!); // 保存到文件 var file new File(/data/local/tmp/native.dex, wb); file.write(Memory.readByteArray(dexData, size)); file.flush(); file.close(); } } });注意事项libdvm.so仅存在于 Android 4.4 及以下Android 5.0 使用 ART 虚拟机对应函数是art::DexFile::OpenMemory符号名更复杂需用Module.enumerateSymbols()动态查找。3.4 ART 层终极方案art::DexFile::OpenMemoryAndroid 5.0ART 时代DexFile::OpenMemory是最接近源头的 hook 点。但它没有导出符号需通过Module.findBaseAddress(libart.so)计算偏移。我整理了主流 Android 版本的偏移表基于 AOSP 源码Android 版本libart.so 基址偏移函数签名5.0–5.10x1a2c30OpenMemory(uint8_t*, size_t, const std::string, uint32_t*, bool)6.0–7.10x1b8f40OpenMemory(const uint8_t*, size_t, const std::string, uint32_t*, bool)8.0–9.00x1d4a50OpenMemory(const uint8_t*, size_t, const std::string, uint32_t*, bool, std::string*)hook 脚本需动态判断 Android 版本var androidVersion Device.getApiLevel(); var libart Module.findBaseAddress(libart.so); var offset; if (androidVersion 26 androidVersion 28) { // 8.0–9.0 offset ptr(0x1d4a50); } else if (androidVersion 23 androidVersion 25) { // 6.0–7.1 offset ptr(0x1b8f40); } else { offset ptr(0x1a2c30); // 5.0–5.1 } var openMemoryAddr libart.add(offset); Interceptor.attach(openMemoryAddr, { onEnter: function (args) { console.log([] art::DexFile::OpenMemory called); console.log([*] DEX data: args[0]); console.log([*] Size: args[1]); // 后续读取逻辑同 dvmDexFileOpenPartial } });避坑经验ART 的OpenMemory第二个参数size是size_t在 64 位设备上为 8 字节parseInt(args[1])会截断高位。必须用args[1].toInt32()或args[1].readU32()取决于 ABI。4. dexdump 的深度使用与内存 dump 的精度控制很多人以为dexdump只是个“dump 就完事”的黑盒工具其实它的参数组合决定了你能看到多少有效信息。而内存 dump 的精度——即baseAddr和size的准确性——直接决定dexdump输出是否可读。这两者必须协同优化。4.1 dexdump 的核心参数详解不只是-ddexdump的常用参数看似简单但每个都有深意-ddisassemble输出 smali-like 伪代码含.method、.registers、.line等适合分析逻辑流。但注意它会跳过被Keep注解但实际被 ProGuard 删除的方法导致输出缺失。-ffile info只打印 DEX header 信息如checksum: 0x12345678、signature: aabbccdd...、file_size: 1234567。这是验证 dump 是否完整的首要步骤——file_size必须与你Memory.readByteArray读取的长度一致否则dexdump会报Invalid DEX file。-hheader only仅输出 header 结构不含 class 列表速度快适合批量校验。-cclass list只列出所有类名不含方法体用于快速确认包名和类数量是否符合预期如业务 APK 通常有 2000 类而壳只有 50 个。实操建议流程先用dexdump -f raw.dex检查file_size和header_size若file_size为 0 或远小于预期说明size参数太小需扩大扫描范围若file_size正确但-d输出报错Bad encoded_value说明 DEX 被二次混淆如字符串加密、控制流扁平化此时-c仍可列出类名证明 dump 成功最终用-d输出完整逻辑配合grep Lcom/example/筛选业务类。4.2 内存 dump 的三重精度保障地址、大小、对齐Memory.readByteArray(baseAddr, size)的三个参数任何一个不准dexdump就会失败。我总结出三重保障机制第一层地址对齐AlignmentDEX 文件头必须 4 字节对齐但Memory.scan()返回的address是精确到字节的。若address 0x7f12345003直接读会因未对齐导致dexdump解析失败。必须向下取整到 4KB 页面边界var pageAlignedAddr baseAddr.and(ptr(0xfffffffffffff000)); // 0xfffff000 是 4KB mask console.log([*] Page-aligned address: pageAlignedAddr);第二层大小推算Size Estimationdexdump -f输出的file_size是唯一权威标准但你不能等dexdump运行完才知道。需在 Frida 中预估DEX header 固定 0x70 字节string_ids_size字段位于 offset0x58占 4 字节class_defs_size字段位于 offset0x6c占 4 字节理论最小 size 0x70 string_ids_size * 4 class_defs_size * 32每个 class_def 占 32 字节。Frida 中读取 headervar header Memory.readByteArray(baseAddr, 0x70); var view new Uint8Array(header); var fileSize (view[0x20] | (view[0x21] 8) | (view[0x22] 16) | (view[0x23] 24)); // offset 0x20 is file_size console.log([*] Estimated file_size from header: fileSize);第三层冗余读取Redundancy为防 header 被篡改我习惯多读 1MBfileSize 0x100000再用dexdump -f校验。若失败逐步减半直到找到最小有效 size。脚本中实现var candidateSizes [fileSize, fileSize 0x10000, fileSize 0x100000]; for (var i 0; i candidateSizes.length; i) { var bytes Memory.readByteArray(pageAlignedAddr, candidateSizes[i]); // 保存为 temp.dex 并调用 dexdump -f // 若成功break否则 continue }4.3 一次完整的脱壳实操从 hook 到 jadx 反编译以某金融类 AppAndroid 11某信加固为例记录完整链路设备准备Pixel 3a已 rootFrida server 15.1.22 运行中启动 Fridafrida -U -f com.xxx.bank -l bank_hook.js --no-pausehook 脚本优先 hookDexClassLoader.init未捕获dexPath自动 fallback 到BaseDexClassLoader.findClass内存扫描在findClass中触发Memory.scan1.3 秒后找到地址0x7f1a2b3c00dump 内存Memory.readByteArray(0x7f1a2b3c00, 0x1a2c30)预估 size→ 保存为/data/local/tmp/bank_raw.dex校验adb shell dexdump -f /data/local/tmp/bank_raw.dex→ 输出file_size: 1082345header_size: 112解析adb shell dexdump -d /data/local/tmp/bank_raw.dex /data/local/tmp/bank_dump.txt拉取adb pull /data/local/tmp/bank_dump.txt ./反编译jadx -d ./bank_jadx ./bank_raw.dex注意用原始.dex文件不是.txt验证jadx-gui打开./bank_jadx, 搜索com.xxx.bank.MainActivity确认方法体完整无// $FF: Couldnt be decompiled注释。关键耗时统计实测 5 次平均Frida 注入到 hook 触发2.1 秒Memory.scan耗时1.3 秒Memory.readByteArray0.4 秒dexdump -f0.2 秒dexdump -d8.7 秒取决于 DEX 大小总耗时约 13 秒全程无需手动干预。经验dexdump -d耗时长是正常的它在做完整的字节码验证和符号解析。若卡在 30 秒以上大概率是size错误导致无限循环应立即中断并检查dexdump -f输出。5. 常见失败归因与可复现的调试清单脱壳失败不是玄学而是有迹可循的工程问题。我把过去两年踩过的 137 个坑归为五类并给出每类的可复现调试步骤。当你遇到问题不要重写脚本先按此清单逐项验证。5.1 Frida 注入失败Failed to attach或Script crashed现象frida -U -f com.xxx.xxx后无输出或报Error: unable to find process。归因与调试SELinux 限制Android 8.0 默认enforcingFrida server 需setenforce 0。✅ 复现步骤adb shell getenforce→ 若输出Enforcing执行adb shell su -c setenforce 0Frida server 版本不匹配ARM64 设备用 ARM 版 server 会静默失败。✅ 复现步骤adb shell file /data/local/tmp/frida-server→ 确认aarch64App 设置了android:debuggablefalse且未 rootFrida 无法 attach。✅ 复现步骤adb shell dumpsys package com.xxx.xxx | grep debuggable→ 若为false必须 root。5.2 Hook 未触发脚本运行无日志现象Frida 控制台空console.log一句没输出。归因与调试Java 类未加载DexClassLoader类在Application.onCreate()前未初始化。✅ 复现步骤frida -U -f com.xxx.xxx -l debug.js脚本中Java.perform(() { console.log(Java env ready); });→ 若无输出说明 Java 层未就绪函数签名错误overload参数类型写错如String写成java.lang.String正确 vsString错误。✅ 复现步骤Java.use(dalvik.system.DexClassLoader).$init.overloads.forEach(o console.log(o));→ 查看实际 overload 列表Android 版本适配缺失Android 10 的DexClassLoader构造函数新增参数。✅ 复现步骤adb shell getprop ro.build.version.sdk→ 若 ≥ 29需 hookoverload(java.lang.String, java.lang.String, java.lang.String, java.lang.ClassLoader, java.lang.String)。5.3 内存扫描无结果onMatch从未调用现象Memory.scan执行完控制台无[] Found DEX header。归因与调试扫描范围错误只扫r--但 DEX 在rwx。✅ 复现步骤Process.enumerateRanges(rwx).forEach(r console.log(r.base - r.base.add(r.size)));→ 确认存在大块rwx区域Pattern 错误用了\n而非0a。✅ 复现步骤Memory.readByteArray(addr, 8).map(b b.toString(16))→ 手动确认魔数是否为64 65 78 0a 30 33 35 00壳使用了 DEX 分片真实 DEX 被拆成多段首段无魔数。✅ 复现步骤扫rwx区域所有0x1000对齐地址用hexdump -C检查每段开头 16 字节。5.4 dexdump 解析失败Invalid DEX file或Bad checksum现象dexdump -f raw.dex报错或file_size为 0。归因与调试地址未对齐baseAddr不是 4KB 对齐。✅ 复现步骤printf %x\n $((0x7f1a2b3c03 0xfffffffffffff000))→ 确认对齐后地址size 过小只读了 header没读完整文件。✅ 复现步骤head -c 112 raw.dex | hexdump -C→ 确认file_size字段值再dd ifraw.dex oftest.dex bs1 count$file_size测试壳做了 header 混淆file_size字段被异或加密。✅ 复现步骤用xxd raw.dex | head -20查看00000050:行file_size位于 offset0x20若值异常如 0