
1. 项目概述为什么我们要从内存里“捞”DEX在安卓应用安全分析和逆向工程领域加固技术就像给应用穿上了一层“盔甲”而梆梆加固是其中非常知名且应用广泛的一款。它的核心作用之一就是对应用原始的DEX文件包含Java代码逻辑进行加密、混淆、隐藏甚至运行时动态加载以防止静态反编译工具直接获取代码。对于安全研究员、逆向工程师或者只是想学习某个应用实现逻辑的开发者来说这堵墙必须想办法绕过。传统的静态脱壳方法在面对梆梆这类强运行时保护的加固时往往力不从心。这时动态分析工具就成为了破局的关键。Frida这个强大的动态代码插桩框架允许我们在应用运行时注入自己的JavaScript脚本去监视、修改甚至直接操作应用的内存和逻辑。我们的目标很明确在应用将解密后的DEX文件加载到内存中执行的那一刻从内存里把它完整地“捞”出来也就是所谓的“Dump”。这个项目就是一次实战演练。我将手把手带你利用Frida编写一个脚本在目标应用以梆梆加固为例运行过程中自动搜索、定位并导出内存中已解密的DEX文件。最终你将获得一个可以直接用反编译工具如Jadx、JEB打开的、清晰的DEX文件。这不仅是一个技术实现更是一种在面对加固保护时的通用思路和武器。2. 核心思路与技术选型解析2.1 为什么选择Frida进行内存Dump面对加固我们有几个技术路径可选静态分析、动态调试、内存取证。静态分析在加固面前基本失效动态调试如IDA Pro、GDB门槛高且容易被反调试机制检测。Frida的优势在于其“无侵入”或“低侵入”的动态插桩能力。Frida的核心原理是注入一个名为frida-server的守护进程到目标设备或模拟器中然后通过我们编写的JavaScript脚本利用Frida提供的API去挂钩Hook目标进程的关键函数或者直接扫描其内存空间。整个过程就像给应用安装了一个“内窥镜”我们可以在外部PC上通过Python或JavaScript控制这个“内窥镜”去看、去拿内存里的数据。选择Frida进行Dump操作主要基于以下几点考量跨平台与语言友好Frida支持主流的桌面和移动操作系统脚本使用JavaScript编写对于广大开发者而言学习曲线相对平缓。强大的内存操作APIFrida提供了Memory.scan()、Memory.readByteArray()等底层API能让我们精细地搜索和读取进程内存。灵活的注入时机我们可以在应用启动时spawn或运行时attach注入脚本确保能捕捉到DEX被加载到内存的关键时刻。社区生态丰富围绕Frida有大量现成的脚本和工具如FRIDA-DEXDump、objection提供了很好的参考和起点。2.2 理解DEX文件在内存中的“指纹”要在茫茫内存中找到DEX文件我们必须知道它的特征。一个标准的、未加密的DEX文件在文件头部有一个固定的魔术字Magic Number和版本号。最常见的格式是dex\n035\0ASCII表示或十六进制的64 65 78 0a 30 33 35 00。这个035代表DEX文件格式的版本号。加固厂商当然知道这个特征。因此高级的加固方案会加密完整DEX将整个DEX文件加密后存储运行时解密整个文件到内存。隐藏文件头解密后可能将DEX文件头信息抹去或修改或者将DEX数据块打散存放。动态加载不一次性加载整个DEX而是按需解密、加载类或方法。我们的脚本基础策略就是基于最经典的dex\n035这个特征进行暴力搜索。为什么这仍然有效因为无论加固如何变形最终要能被Android RuntimeART/Dalvik正确执行加载到内存中的、准备被解释或编译的代码段其结构必须符合DEX规范。dex\n035这个标识就是规范的一部分。加固可能会在加载前一刻才还原它但只要我们搜索的时机够准比如在ClassLoader加载类之后就很有可能抓到它。注意这种方法通常被称为“暴力搜索”或“特征码搜索”。它不一定100%成功特别是面对最新版本、做了深度定制混淆的加固时可能需要调整特征码或结合其他启发式方法。但对于许多常见版本和场景它仍然是最高效、最直接的手段。2.3 工具链准备与环境搭建工欲善其事必先利其器。开始编写脚本前你需要准备好以下环境测试设备/模拟器一台已经Root的安卓物理手机或者一台安卓模拟器如雷电模拟器、夜神模拟器。Root权限是必须的因为Frida需要向系统进程注入代码。模拟器通常自带Root是初学者的首选。Frida环境PC端通过Python的pip安装Frida和Frida-tools。pip install frida frida-tools设备端根据设备CPU架构通常是arm或x86从Frida官网下载对应版本的frida-server。将其推送到设备赋予执行权限并运行。adb push frida-server /data/local/tmp/ adb shell su cd /data/local/tmp chmod 755 frida-server ./frida-server 目标应用一个被梆梆加固或其他你想测试的加固保护的应用。可以在一些安全论坛或测试资源站找到样本。开发与调试工具一个文本编辑器或IDE如VSCode用于写脚本以及adb命令行工具用于连接设备。3. 脚本核心逻辑与分步实现我们的脚本将分为几个核心模块附加进程、内存搜索、特征验证、数据Dump。下面我们一步步拆解。3.1 建立连接与附加目标进程首先我们需要让Frida连接到目标设备上的应用进程。// 1. 连接到本地设备假设frida-server运行在本地设备的默认端口27042 let device Frida.getLocalDevice(); // 2. 枚举所有进程找到我们的目标 let processes device.enumerateProcesses(); let targetProcess null; for (let proc of processes) { // 假设我们的目标应用包名是 com.example.hardenedapp if (proc.name.indexOf(com.example.hardenedapp) ! -1) { targetProcess proc; console.log([] 找到目标进程: ${proc.name} (PID: ${proc.pid})); break; } } if (!targetProcess) { console.log([-] 未找到目标进程请确保应用已启动。); return; } // 3. 附加到该进程 let session device.attach(targetProcess.pid); console.log([] 已附加到进程 PID: ${targetProcess.pid});这里有一个关键技巧附加进程的时机。如果应用有反调试或反附加检测在应用启动后再附加attach可能会触发保护导致应用崩溃。更稳妥的方式是使用spawn产卵方式让Frida在应用启动前就介入然后恢复执行。// 使用spawn方式更推荐可绕过部分反调试 let pid device.spawn([com.example.hardenedapp]); console.log([] 应用已spawnPID: ${pid}); let session device.attach(pid); // 先不恢复执行等我们脚本准备好后再resume device.resume(pid); // 稍后在合适时机调用 session.detach(); // 脚本执行完后分离3.2 内存暴力搜索DEX特征附加成功后我们就可以扫描进程的内存空间寻找dex\n035这个特征了。Frida提供了Memory.scan()函数。// 定义我们要搜索的DEX文件头特征码 // dex\\n035\\0 的十六进制表示: 64 65 78 0a 30 33 35 00 const DEX_HEADER_PATTERN 64 65 78 0a 30 33 35 00; // 有时也需要搜索旧版本如 dex\\n036\\0 (Android 8.0) // const DEX_HEADER_PATTERN_036 64 65 78 0a 30 33 36 00; let dexFoundAddresses []; // 回调函数当搜索到匹配项时被调用 Memory.scan(session, 0, Process.pageSize, DEX_HEADER_PATTERN, { onMatch: function(address, size){ console.log([] 在地址 ${address} 发现可能的DEX头 (大小: ${size})); dexFoundAddresses.push(address); }, onComplete: function(){ console.log([] 内存扫描完成共发现 ${dexFoundAddresses.length} 个潜在DEX位置。); // 接下来进入验证和Dump阶段 if (dexFoundAddresses.length 0) { verifyAndDumpDex(dexFoundAddresses[0]); // 以第一个为例 } } });实操心得Memory.scan()的第三个参数size是扫描的内存范围大小。这里我写的是Process.pageSize这只是一个示例。实际上为了不漏掉DEX我们需要扫描整个进程的可读内存区域。更专业的做法是先通过Process.enumerateRanges(r--)或r-x获取所有有读权限的内存范围然后对每个范围进行扫描。直接扫描整个地址空间0到最大在64位系统上不现实且低效。3.3 验证与计算DEX文件大小找到一个以dex\n035开头的地址并不代表从这里开始就是一整个完整的、有效的DEX文件。它可能只是一个残留的副本或者后面紧接着是其他数据。因此我们需要进行验证并计算出这个DEX在内存中的准确长度。一个标准的DEX文件头结构dex_header里有一个字段叫file_size位于文件头起始偏移的0x20处占4个字节小端序。我们可以读取这个值来得知DEX文件的理论大小。function verifyAndDumpDex(baseAddress) { console.log(\n[*] 开始验证地址 ${baseAddress} 处的DEX...); // 1. 读取DEX头部的 file_size 字段 (偏移 0x20) let fileSizePtr baseAddress.add(0x20); let fileSizeBytes Memory.readByteArray(fileSizePtr, 4); // 将4字节的小端序数据转换为整数 let fileSize fileSizeBytes.readUInt32LE(0); console.log( [] 从头部读取的 file_size 字段值: 0x${fileSize.toString(16)} (${fileSize} 字节)); // 2. 简单的合理性验证 if (fileSize 0x70 || fileSize 100 * 1024 * 1024) { // 小于头部大小或大于100MB认为不合理 console.log( [-] file_size 值 ${fileSize} 看起来不合理可能不是有效的DEX头。); return; } // 3. 可选进一步验证DEX头其他字段如 checksum, signature 等 // 这里可以添加更复杂的校验逻辑例如计算后续数据的SHA-1并与头部的signature字段对比。 // 但对于快速Dump脚本file_size的合理性检查通常是第一步。 console.log( [] 验证通过准备Dump ${fileSize} 字节的数据。); dumpMemoryToFile(baseAddress, fileSize, dump_${baseAddress}.dex); }3.4 内存数据转储与文件保存验证通过后我们就可以从内存中读取指定大小的数据并保存到PC上的文件了。function dumpMemoryToFile(startAddress, size, filename) { console.log( [*] 正在从 ${startAddress} 读取 ${size} 字节...); try { let dexBytes Memory.readByteArray(startAddress, size); console.log( [] 内存读取成功获得 ${dexBytes.length} 字节数据。); // 将数据保存到文件这里需要依赖Frida的File API或通过RPC传递回Python端 // 方法A使用Frida的File API如果目标环境支持通常用于保存到设备 // let file new File(/sdcard/ filename, wb); // file.write(dexBytes); // file.close(); // console.log( [] 数据已保存到设备: /sdcard/${filename}); // 方法B推荐通过RPC将数据发送回控制脚本的Python程序由Python保存到PC。 // 这需要在脚本中声明一个RPC函数。 send({action: dump, filename: filename, data: ArrayBuffer.from(dexBytes)}); console.log( [] 已通过RPC发送数据请在Python端查看文件。); } catch (e) { console.log( [-] Dump失败: ${e.message}); } }这里引出了一个关键点如何将内存中的二进制数据从设备弄到PC上直接在JS里写设备文件有时权限不足且不方便管理。更优雅的方式是使用Frida的RPCRemote Procedure Call机制。我们在JS脚本中暴露一个函数给外部调用或者通过send()函数将数据发回给控制端Python脚本。3.5 整合与优化完整的脚本框架将以上模块整合并加入RPC支持形成一个完整的、可交互的脚本。// frida_dump_dex.js rpc.exports { // 供Python端调用的函数扫描并Dump所有DEX dumpAllDex: function () { return new Promise((resolve, reject) { let results []; // 获取所有可读内存范围 Process.enumerateRanges(r--).forEach(range { console.log([*] 扫描范围: ${range.base} - ${range.base.add(range.size)}); Memory.scan(range.base, range.size, DEX_HEADER_PATTERN, { onMatch: function (address, size) { console.log( [] 发现特征 ${address}); let dexInfo verifyDexHeader(address); if (dexInfo.valid) { console.log( - 有效DEX大小: ${dexInfo.size}); let data Memory.readByteArray(address, dexInfo.size); results.push({ address: address, size: dexInfo.size, data: ArrayBuffer.from(data) // 转换为ArrayBuffer便于传输 }); } }, onComplete: function () { // 一个范围扫描完成 } }); }); // 注意这里需要更复杂的同步逻辑来等待所有扫描完成 // 简化起见我们使用setTimeout等待一小段时间实际脚本应用更严谨的信号控制 setTimeout(() { resolve(results); }, 3000); // 等待3秒 }); }, // 供Python端调用的函数Dump指定地址的数据 dumpAtAddress: function (addrStr, size) { let address ptr(addrStr); let data Memory.readByteArray(address, size); return ArrayBuffer.from(data); } }; // 独立的验证函数 function verifyDexHeader(address) { let result { valid: false, size: 0 }; try { let fileSizePtr address.add(0x20); let fileSizeBytes Memory.readByteArray(fileSizePtr, 4); let fileSize fileSizeBytes.readUInt32LE(0); // 基础合理性检查 if (fileSize 0x70 fileSize 50 * 1024 * 1024) { // 调整最大阈值 // 可选检查magic和版本号是否完全匹配 let magicBytes Memory.readByteArray(address, 8); let magicStr ; for (let i 0; i 8; i) { magicStr String.fromCharCode(magicBytes[i]); } if (magicStr dex\n035\0 || magicStr dex\n036\0 || magicStr dex\n037\0) { result.valid true; result.size fileSize; } } } catch (e) { } return result; } // 脚本加载后立即执行的逻辑如果需要 console.log([] Frida DEX Dump 脚本已加载。); console.log([] 通过 rpc.exports.dumpAllDex() 或 dumpAtAddress() 调用功能。);对应的Python控制端脚本# dump_runner.py import frida import sys import time def on_message(message, data): if message[type] send: print(f[*] JS Log: {message[payload]}) elif message[type] error: print(f[-] JS Error: {message}) def main(target_package): # 连接设备 device frida.get_usb_device() # 附加到目标进程或spawn try: # 方式1: Attach到已运行进程 session device.attach(target_package) except frida.ProcessNotFoundError: # 方式2: Spawn新进程 pid device.spawn([target_package]) session device.attach(pid) device.resume(pid) print(f[] 已Spawn并附加到进程: {pid}) time.sleep(2) # 等待应用初始化 # 加载JS脚本 with open(frida_dump_dex.js, r, encodingutf-8) as f: js_code f.read() script session.create_script(js_code) script.on(message, on_message) script.load() # 调用JS脚本中暴露的RPC函数 dump_all script.exports_sync.dump_all_dex() print(f[] 共找到 {len(dump_all)} 个DEX文件。) for idx, dex in enumerate(dump_all): addr dex[address] size dex[size] data dex[data] # 这是一个list of ints (bytes) filename fdex_dump_{idx}_{addr}.dex with open(filename, wb) as f: # 将list of ints 转换为 bytes 并写入文件 f.write(bytes(data)) print(f [] 已保存: {filename} (地址: {addr}, 大小: {size})) # 保持会话防止脚本被卸载 print([*] Dump完成。按CtrlC退出。) sys.stdin.read() session.detach() if __name__ __main__: if len(sys.argv) ! 2: print(f用法: python {sys.argv[0]} 应用包名) sys.exit(1) main(sys.argv[1])4. 实战进阶应对梆梆加固的挑战与技巧基础的暴力搜索脚本可能无法应对所有情况尤其是像梆梅加固这样不断升级的产品。下面分享一些进阶的实战技巧。4.1 定位动态加载的DEX高级加固不会在应用启动时就把所有解密后的DEX一次性映射到内存。它们往往采用“按需加载”的策略即只有当某个类被首次访问时才解密对应的代码段。这意味着我们的扫描时机非常重要。策略一延时扫描与循环扫描不要在附加后立即扫描而是等待应用启动完成、主要活动界面出现后再进行。甚至可以设置一个循环每隔几秒扫描一次以捕捉不同阶段加载的DEX。setTimeout(function() { console.log([*] 应用启动后延迟10秒开始扫描...); scanAllMemoryForDex(); }, 10000); // 或者循环扫描 let scanInterval setInterval(scanAllMemoryForDex, 5000); // 扫描足够次数后清除定时器 setTimeout(() clearInterval(scanInterval), 60000);策略二Hook关键加载函数更精准的方式是Hook Android系统中负责加载DEX的核心函数。例如dalvik.system.DexClassLoader或BaseDexClassLoader的构造函数以及DexFile类的openDexFile/loadDex等原生方法。当这些函数被调用时其参数往往包含了DEX文件的路径或内存地址。// Hook DexClassLoader 构造函数Java层 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 被调用:); console.log( dexPath: ${dexPath}); console.log( optimizedDirectory: ${optimizedDirectory}); // 在这里可以触发对dexPath文件的读取或者尝试从内存中寻找已加载的该DEX // 注意dexPath可能是文件路径加固应用可能使用自定义的ClassLoader这个Hook可能抓不到。 return this.$init(dexPath, optimizedDirectory, librarySearchPath, parent); }; });Hook底层原生函数如libart.so中的OpenMemory效果更好但需要对ART虚拟机有更深理解且偏移地址随Android版本变化。4.2 处理多DEX与DEX碎片化一个应用可能包含多个DEX文件如classes.dex,classes2.dex, ...加固后这些文件可能被合并、拆分或混淆。我们的暴力搜索脚本会找到所有匹配dex\n035的地址每个地址都可能是一个独立的DEX。问题有时找到的DEX在内存中并不连续或者头部和后续数据被其他内存区域隔开碎片化。直接按照file_size读取可能会读到错误的数据或导致访问违例。解决方案边界检查在读取file_size大小的数据前检查从startAddress到startAddress.add(fileSize)这个内存范围是否都在同一个具有读权限的内存区域MemoryRange内。如果不是则只读取该区域内的有效部分。启发式合并如果找到多个DEX头且它们地址相近可以尝试分析它们是否属于同一个大的DEX被拆分。这需要更复杂的DEX结构解析。使用成熟工具参考像FRIDA-DEXDump这样的开源工具已经处理了很多边界情况。其核心思路是找到DEX头后不仅读取file_size还会解析DEX结构中的map_off和map_size因为map段包含了DEX文件所有部分的索引和大小用它来指导Dump往往更准确。4.3 对抗反Frida检测梆梆加固等商业产品很可能集成了反Frida机制。常见检测手段包括检测frida-server进程名或端口。检测内存中Frida相关字符串或特征码如“frida”、“gum-js-loop”、“frida-agent”。检测ptrace附加反调试。检测线程名Frida会创建特定名称的线程。对抗措施重命名frida-server将frida-server文件改名为其他名字如/data/local/tmp/fs并运行。使用非常规端口启动frida-server时指定非默认端口./frida-server -l 0.0.0.0:8080连接时也指定端口。使用隐藏工具如objection的anti-anti-frida插件或使用定制编译的Frida去除特征字符串。脚本中主动抹去痕迹在JS脚本中可以尝试Hook检测函数并返回假值或者手动修改内存中的特征字符串。使用spawn模式如前所述在应用启动前注入比运行时attach更隐蔽。这是一个简单的反检测示例Hook一个可能存在的检测函数Java.perform(function() { // 假设应用有一个检测Frida的类 var AntiFrida Java.use(com.secshell.AntiFrida); if (AntiFrida) { AntiFrida.check.implementation function() { console.log([*] AntiFrida.check() 被调用返回 false 绕过。); return false; }; } });5. 常见问题排查与脚本调试实录在实际操作中你肯定会遇到各种问题。下面是我踩过的一些坑和解决方法。5.1 脚本注入失败或应用崩溃现象执行Python脚本时出现Failed to attach: unable to connect to remote frida-server或应用一注入就闪退。排查步骤检查frida-server确保设备上的frida-server正在运行ps | grep frida并且PC端Frida版本与frida-server版本匹配frida --version查看。检查连接确保adb devices能看到设备并且Frida连接的是正确设备frida-ps -U列出USB设备上的进程。关闭冲突关闭其他可能占用端口的程序或者关闭电脑防火墙/杀毒软件临时测试。反调试导致崩溃如果应用使用了强反调试attach模式极易触发。务必尝试使用spawn模式见3.1节代码。如果spawn后应用仍崩溃可能是应用有SIGSEGV信号处理或定时检测。可以尝试在spawn后、resume前先让Frida脚本挂上一些关键的反调试检测函数的Hook。权限问题确保设备已Root并且frida-server是以root权限运行的。5.2 扫描不到任何DEX特征现象脚本运行后dexFoundAddresses数组为空。可能原因与解决特征码不匹配目标DEX可能使用了不同的版本号如dex\n036、dex\n037。尝试同时搜索多个特征码。const PATTERNS [64 65 78 0a 30 33 35 00, 64 65 78 0a 30 33 36 00, 64 65 78 0a 30 33 37 00]; PATTERNS.forEach(pattern Memory.scan(..., pattern, ...));加固深度混淆加固可能完全抹去了标准文件头或者在内存中从未以完整、标准的DEX形态出现。这时需要寻找其他特征例如ART虚拟机内部数据结构如DexFile对象的特征或者HookdvmDexFileOpenPartial/art::DexFile的构造函数来获取指针。这需要更深入的研究。扫描时机不对DEX尚未被加载。尝试在应用界面完全加载后或者触发某个功能如点击登录按钮后再执行扫描脚本。使用延时或循环扫描。扫描范围不全脚本只扫描了部分内存。确保使用了Process.enumerateRanges(r--)遍历了所有可读内存区域。内存访问权限即使区域标记为可读也可能因内存分页等原因访问失败。Memory.scan内部会处理异常但确保你扫描的是进程自身的内存空间。5.3 Dump出的DEX文件无法反编译现象用Jadx打开Dump出的文件提示“Not a valid dex file”或“Error loading dex file”。诊断与修复检查文件大小用十六进制编辑器如010 Editor打开Dump的文件查看文件大小是否与file_size字段一致。如果不一致说明Dump不完整。检查文件头查看文件开头8个字节是不是64 65 78 0a 30 33 35 00。如果不是说明找错了地址或者数据被截断/错位。修复DEX头有时Dump的数据起始点不是真正的DEX文件开头可能前面有一些填充数据。尝试在文件数据中搜索64 65 78 0a找到真正的起始偏移然后从那里开始截取file_size字节的数据另存为新文件。DEX修复工具尝试使用dexfixer等工具修复DEX文件。有时内存中的DEX结构略有损坏修复工具可以校正校验和等字段。多DEX合并如果应用有多个DEX需要将它们全部Dump下来。Jadx可以自动处理多个DEX文件将它们放在同一目录下打开即可。5.4 性能问题与优化现象扫描整个内存空间速度很慢甚至导致应用卡顿。优化建议缩小扫描范围不要扫描所有r--区域。优先扫描rw-读写和r-x可执行区域代码和已加载的DEX更可能出现在这些区域。r--区域可能是只读数据包含DEX的概率相对较低。let ranges Process.enumerateRanges(rw-).concat(Process.enumerateRanges(r-x));分块扫描与异步Memory.scan是同步的扫描大内存会阻塞。可以尝试将大内存范围分成小块用setImmediate或Promise进行异步扫描避免脚本执行超时。使用更高效的工具对于稳定的需求可以考虑将核心扫描逻辑用C语言写成Frida的Module性能远高于JS。最后分享一个我个人的调试习惯在脚本的关键节点加入详细的日志并不仅仅用console.log输出地址而是输出附近的内存内容Memory.readByteArray(address, 64)转Hex这能帮你直观地判断找到的东西是否正确。耐心和细致的观察是解决内存Dump中各种古怪问题的关键。这个脚本是一个起点面对不同的加固版本和场景你需要灵活调整策略甚至结合静态分析和动态调试的其他手段才能达到最好的效果。