
最近处理一个被 DexProtector 加固的安卓样本时我差点被它整破防。jadx 打开是能打开classes.dex 也在可方法体全是 nop字符串全部加密native 库里还塞了一整套自定义虚拟机指令。常规思路——静态分析、直接改校验、跑现成的脱壳机——全部碰壁。最后真正破局的方向其实是回到 ART 的原理本质不管壳把 DEX 藏得再深只要业务代码还要执行明文 DEX 终归要在进程内存里以某种形态存在而这种形态往往就落在匿名的内存映射段上。这篇文章想完整梳理一下我用内存态匿名段分析绕过 DexProtector 检测的整条策略链路覆盖检测面识别、环境规避、匿名段定位、多轮 dump、镜像修复和验证方法。适合已经有 Frida 基础、正在跟商业加固死磕的安卓逆向研究者也适合刚接触 DEX 加固想建立完整思路的入门者。需要先说明的是这类分析只建议用于授权 App 的安全测试、CTF 复现和恶意样本分析拿到第三方应用直接脱壳属于越界操作后果要自己扛。1. DexProtector 的防御模型决定了静态脱壳天然失效1.1 传统加固壳和 DexProtector 的本质差异传统加固壳的逻辑通常很直白把原始 DEX 加密成 blob运行的时候用 ClassLoader 解密加载/data/data/包名/下甚至能找到解密后的文件或者在内存里能直接看到完整镜像。对付这种壳静态扫描文件系统加一个frida-dexdump基本就能解决问题。DexProtector 的逻辑完全不同。它来自 Licel 公司被大量用在银行、政务、游戏和付费内容类 App 上核心特点不是加密一层壳而是把 DEX 拆成了多段进行处理。磁盘上的 classes.dex 可能是一个仅供入口使用的极简 DEX真正的业务代码被打散、加密后藏在 assets 或者 so 文件里运行过程中native 层会按需解密这些片段直接把原始字节铺进进程地址空间的匿名内存段中文件系统层面完全看不到中间产物。这就是内存态匿名段这个说法的来源明文 DEX 没有对应任何磁盘文件而是活在mmap匿名映射或者堆分配出来的内存区域里。只要这段内存还在它就在/proc/pid/maps里表现为没有文件路径的匿名段。1.2 静态扫描和单纯 hook 为什么靠不住静态扫描失效的原因很好理解。DexProtector 在打包阶段就对方法体做过抽取处理很多方法在磁盘上的字节码区域是空的或直接被填充成了 nop真正的内容要等类加载时才在内存里回填。你拿 jadx 反编译一个已经脱壳但没修复抽取的 DEX会看到类的字段和方法签名都在但方法体全是空的代码逻辑完全无法追踪。单纯 hook 也不行。DexProtector 对自身运行链路有完整的校验机制它的 native 层会持续检查关键数据段是否被改写、方法是否被提前调用、进程里是否存在陌生线程。贸然在 Java 层挂一堆 hook往往在业务代码还没跑起来之前就触发了反调试逻辑进程直接退出或者把内存里的明文数据全部抹掉给你一个干干净净的死样本。我踩过最离谱的一次在Java.use层 hook 了BaseDexClassLoader.loadClass刚加载到第三个类就触发崩溃回看日志发现 DexProtector 的 native 线程在持续扫描/proc/self/maps发现异常映射后主动调用了exit。所以这个壳不是有任何单一漏洞而是它的检测面覆盖了从启动到业务运行的全过程任何一个细小的异常都可能成为压死分析的稻草。1.3 为什么最后只能走内存态匿名段把静态和 hook 都排除后剩下的路径就非常清晰了只要 App 还要执行业务逻辑ART 虚拟机就必须把明文 DEX 交给 dex2oat 或者解释器这一步一定会发生在进程内存中。DexProtector 能做的只是让这段内存不落盘、不暴露明显特征、只在需要时短暂存在但做不到让 DEX 凭空消失。关键问题变成了两个明文 DEX 被放在哪个匿名段里以及在什么时候这段内存是完整可读的。这两个问题正是内存态匿名段分析要解决的核心也是本篇文章后续所有操作的基础。提示这里说的检测绕过技术含义是让 App 的反调试、反注入、完整性校验不干扰你的分析流程而不是篡改校验逻辑本身。合法逆向分析的目的是读取真实逻辑不是劫持或者破解商业软件。2. 检测绕过的第一步把反调试、反注入、完整性校验的底摸清2.1 检测面拆解调试、注入、完整性三座大山DexProtector 的检测不是单一机制而是分层的组合拳。我习惯把它拆成三块来看这样起码不会在一开始就把自己绕晕。检测类别常见机制绕过思路调试器检测ptrace 自查、/proc/self/status 的 TracerPid、检测断点指令、时间差不用 gdb 直接 attach改用 frida-gadget 加载模式hook ptrace 相关调用注入检测扫描 /proc/self/maps 里的 frida-agent、gum-js-loop 线程名、默认端口、已加载 so 列表重命名 frida 组件、使用隐藏型 gadget、关掉默认端口完整性校验对 APK、classes.dex、so 文件算 Adler-32、SHA-1、CRC32加载后和常量区对比不改原文件hook 哈希计算函数让其返回原始常量或者在加载前完成 dump调试器检测对干扰最大。DexProtector 的 native 层会自己调用 ptrace 试着附着到自己身上用这个方式占住调试接口第三方调试器一旦附着就会失败。同时它还反复读取/proc/self/status里的 TracerPid 字段发现非零就判定被调试。应对办法是用 frida-server 的 spawn 模式在进程早期介入并且优先把 ptrace、open、read 这几个 libc 导出函数 hook 掉让壳拿到的始终是干净的数据。注入检测主要针对 frida 的默认特征。原版 frida-server 的线程名、agent so 文件名、默认 27042 端口都是指纹扫描工具一抓一个准。我的做法是自己编译 frida-server 并改名把 agent 库用-Wl,--exclude-libs等方式内嵌隐藏同时把默认端口改掉。这样 /proc/self/maps 里看不到明显的第三方映射。完整性校验如果处理不好会出现一个很诡异的现象dump 出来的 dex 看起来完全正常但一运行就崩溃。原因就是 dex 文件某处被我们或测试工具改动过壳层的哈希校验在启动时发现了异常。对分析来说最简单的处理是用 hook 让校验函数永远返回原始值而不是真的去改文件。2.2 反调试与反注入的绕过切入点实际落地的时候我推荐把绕过逻辑放在最早期完成而不是边跑边绕。第一步在 frida 脚本里先声明一系列 interceptor目标集中在ptrace、read、fopen、open、openat、stat、strstr这些系统调用上。对 DexProtector 来说它的反调试核心逻辑最终都会落在读写/proc/self/status和扫描 maps 文件上拦截这几个函数比逐行逆向其 native 逻辑要高效得多。第二步处理 TracerPid。比较糙的做法是直接 hook 读取文件的操作看到 TracerPid 的内容就替换成TracerPid:\t0。DexProtector 的高版本会把路径流转到自定义的readLine函数里这时候单纯 hookfgets可能漏掉建议同时 hookread按返回的字符串内容做过滤。需要注意替换返回值时要保留原有的返回长度不然 CRC 校验会算到错误值。第三步把环境特征收拾干净。真机调试时 root 检测是重灾区我一般用 Magisk 加 Zygisk 再加隐藏模块的方式让检测逻辑扫不到关键目录和 su 二进制。模拟器上的坑更多Build 型号、CPU 指令集、内核天线的特殊节点都会暴露所以有条件尽量用真机。2.3 先绕检测还是先 dump顺序决定成功率这个顺序看起来是常识但实际操作中很多人会急着 dump把检测绕过当成可选步骤。我的经验恰恰相反DexProtector 的防 dump 机制和它的检测逻辑是联动的。一旦检测到分析行为不仅仅是退出进程还可能直接对内存里的 dex 区间做memset清理你就算扫描到了地址也拿不到数据。正确顺序是先完整梳理出壳的检测函数hook 掉所有检测链路确认进程在 frida 附加的状态下稳定运行超过 30 秒不崩溃再去想内存扫描和 dump 的事。这个前置工作大约会占整个分析流程的六成时间但它保证了你后续拿到的数据是可靠的。3. 内存态匿名段的定位与 DEX 镜像识别从 maps 到启发式校验3.1 进程内存布局匿名段和文件映射段的识别把检测绕过去之后分析主战场就转移到进程内存了。第一步老实看/proc/pid/maps格式大概是这样的5e4d0000-5e4f1000 rw-p 00000000 00:00 0 [anon:dalvik-main space] 6f000000-6f002000 r--p 00000000 00:00 0 [anon:dalvik-alloc space] 7a200000-7ae00000 r--p 00000000 fd:01 123456 /system/framework/arm64/boot.oat判断一段区域是不是匿名段看路径字段是 0 还是[anon:xxx]开头就行。DexProtector 解密后的明文 DEX 大概率出现在两类区域里一类是 ART 内部的分配的[anon:dalvik-*]段一类是壳自己用mmap或calloc申请出来的[anon:libc_malloc]或纯数字匿名段。用 Frida 枚举进程内存区域很方便const ranges Process.enumerateRangesSync(r--); for (const range of ranges) { if (!range.file) { console.log(anon ${range.base} ${range.size} ${range.protection}); } }注意权限的组合要放宽一点。明文 DEX 在某些版本里是只读的r--在另一些版本里是rw-如果只扫rwx会漏掉一半可能性。我先全量扫描r--和rw-两种权限再靠后续的魔数校验过滤误报。3.2 在匿名段里搜索 DEX 镜像的启发式方法定位到候选匿名段后接下来是找 DEX 头。DEX 文件的魔数是dex\n对应的十六进制是64 65 78 0a紧随其后的是版本号常见的有035、037、038、039和040。我见过一些版本会把魔数稍微改写但在 DexProtector 的实战样本里标准魔数还是主流。直接扫描魔数会产生大量误报尤其是dex\n出现在字符串池或者随机数据里时。所以启发式校验必须跟上。我的校验逻辑是魔数前 8 字节匹配dex\n0xx新增版本号正则/^0(3|4)[0-9]$/。读取偏移0x20处的 file_size 字段判断是否在安全范围内比如 0x70 到 0x10000000 之间同时不能超过当前匿名段剩余大小。读取偏移0x24处的 header_size正常值是0x70如果出现明显异常值说明是误报。读取偏移0x38到0x40的 string_ids_size 和 string_ids_off粗略判断 string_ids_off 是否落在 file_size 内。对 header 之后的一段数据做 ASCII 字符密度统计DEX 文件的字符串区域通常有大量可打印 UTF-8 文本密度会明显高于随机数据。Frida 扫描核心脚本如下function validateDexHeader(base, size) { if (size 0x70) return null; const magic Memory.readByteArray(base, 8); const m new Uint8Array(magic); if (m[0] ! 0x64 || m[1] ! 0x65 || m[2] ! 0x78 || m[3] ! 0x0a) return null; const version String.fromCharCode(m[4], m[5], m[6]); if (!/^0(3|4)[0-9]$/.test(version)) return null; const fileSize Memory.readUInt(base.add(0x20)); if (fileSize 0x70 || fileSize size) return null; const headerSize Memory.readUInt(base.add(0x24)); if (headerSize ! 0x70) return null; const stringIdsSize Memory.readUInt(base.add(0x38)); const stringIdsOff Memory.readUInt(base.add(0x3c)); if (stringIdsSize 0x100000) return null; if (stringIdsOff fileSize) return null; return { base: base, fileSize: fileSize, version: version }; } function scanDexInRange(base, size) { const results []; for (let off 0; off size - 0x70; off 4) { const addr base.add(off); const dex validateDexHeader(addr, size - off); if (dex) { results.push(dex); } } return results; }扫出来的结果可能不是唯一的系统框架里自带一堆系统 DEX或者壳自己放了几个诱饵副本。这时候要结合后续特征进一步筛选。3.3 多个 dex 镜像并存时怎么区分真身实战中几乎每个进程里都能扫出多个 DEX 镜像至少系统自己的framework.jar之类的就占了好几个位置。区分真身的关键在于内容而不是地址。我常用的是过滤 package name 字符串。DEX 文件的 string data 区域会以 UTF-8 明文保存类全名比如Lcom/some/company/app/MainActivity;。如果要分析的目标包名是com.example.target那我直接扫描候选 DEX 的整个可读区域搜com/example/target字符串能精准命中基本就是真身。第二招是看类数量。壳的入口 stub dex 往往只有几十个类而真身 dex 通常有几千个类。读取 header 偏移0x60处的 class_defs_size 字段少于 50 的基本可以排除。第三招是找特征资源。比如 App 使用 Firebase、支付宝 SDK、微信 SDK 这类第三方库这些类名通常是Lcom/google/firebase/...或者Lcom/alipay/...。真身 dex 里大概率包含了这些第三方类stub 里不包含。综合这几点基本上能在第一轮扫描后锁定 1 到 2 个候选镜像。4. dump 联动流程卡准壳加载时间窗多轮抓取4.1 从 APK 到真身 dex 的完整操作顺序定位到候选 DEX 只是第一步能不能在正确的时间点抓到完整镜像是另一个变量。DexProtector 的防 dump 机制会在检测到异常后抹掉内容所以抓取时机非常重要。我的标准操作顺序解包 APK确认壳类型和版本。看lib/目录下的 native 库名称和 assets 里是否有加密资源通常能判断是 DexProtector 的哪一代产品。对 native 主库做一个快速静态分析找到解密函数或者 DEX 加载函数的符号线索。很多版本会直接调用 ART 的OpenMemory系列 API搜索字符串表里/proc/self/maps、DexFile等字符串能加快定位。用 frida 的 spawn 模式启动 App在早期就完成反调试 hook 和完整性校验的 hook。等待 App 进入主界面或者通过模拟点击触发目标业务逻辑让真实业务类被加载。在整个过程中做至少三轮内存扫描和 dump刚启动后一轮、进入主界面后一轮、触发核心业务逻辑后一轮。对每次 dump 出的 DEX 列表做哈希记录对比哪些是新增、哪些是变化过的。多轮抓取的必要性在于DexProtector 可能不会一次性把完整的 dex 展开而是不同业务模块延迟解密你只抓一轮可能只拿到一部分代码后续类加载后才是完整状态。4.2 关键 hook 点的选择与代码示例如果静态分析能定位到壳调用 ART 加载 DEX 的具体函数效果会大幅提升。最直接的做法是 hookart::DexFile::OpenMemory或者低版本上的art::DexFile::DexFile构造函数。这些函数在 libart 或者 libdexfile 里不同 Android 版本符号名会有差异。以 Android 10 左右的目标为例OpenMemory的常见签名会把 dex 起始地址和大小作为参数hook 思路如下const dexfile Process.findModuleByName(libdexfile.so); if (dexfile) { // 具体偏移需要按版本和实际符号表确定 const openMemory Module.findExportByName(libdexfile.so, _ZN3art7DexFile10OpenMemoryEPKhmRKNSt3__112basic_stringIcNS2_11char_traitsIcEENS2_9allocatorIcEEEEPKNS_6MemMapERKNS_10DexFileOptsEPS9_); if (openMemory) { Interceptor.attach(openMemory, { onEnter(args) { this.dexAddr args[0]; this.dexSize args[1].toInt32(); // 需要确认参数顺序 }, onLeave(retval) { if (this.dexAddr !this.dexSize.isNull()) { send(JSON.stringify({ type: open_memory_dex, addr: this.dexAddr.toString(), size: this.dexSize })); } } }); } }需要注意的地方是参数顺序在不同 Android 版本上不完全一样建议先用符号名和反编译结果交叉确认不要直接照抄。如果符号找不到退而求其次 hook JNI 层的InMemoryDexClassLoader它的参数里包含了你要的缓冲区地址。dump 这段内存的脚本function dumpDex(addr, fileSize, path) { try { Memory.protect(addr, fileSize, rwx); const bytes Memory.readByteArray(addr, fileSize); const file new File(path, wb); file.write(bytes); file.close(); console.log([*] dumped ${fileSize} bytes to ${path}); } catch (e) { console.log([-] dump failed: ${e}); } }dump 的路径要选 App 进程有写权限的地方/data/local/tmp/是需要 root 的但分析时一般都有 root 权限可以直接写。如果想避免权限问题也可以 dump 到 App 的 cache 目录比如/data/user/0/com.example.target/cache/。4.3 为什么要在业务触发后再抓一轮有个新手容易忽略的细节刚启动时抓到的 DEX 往往是入口 stub而不是完整的业务真身。DexProtector 的懒加载策略决定了很多类只有在真正被访问时才从解密函数中展开。如果不触发业务逻辑你拿到的 dex 只有半个壳。我的做法是启动后先用 frida 调用目标 Activity 的关键点击事件或者用Java.perform主动加载几个目标包名下的类强制触发解密流程。强制加载的代码很简单Java.perform(() { const TargetClass Java.use(com.example.target.core.MainService); TargetClass.$new(); // 或者调用任意静态方法 });类一旦加载ART 就会从壳的解密逻辑中取得真实字节码此时再回扫内存段你会发现之前的某个匿名段内容发生了变化多出了很多类和方法体。这个变化量可以直接通过对比多次 dump 的类数量来确认。5. dump 后的镜像修复校验、偏移、抽取还原与验证5.1 dex 头的校验修复Adler-32 与 SHA-1 的坑从内存里 dump 出来的完整 DEX 大概率可以直接打开但常会遇到校验和问题。dex 头里有两个很容易被搞混的区域偏移 0x08 的 checksum 是 Adler-32偏移 0x0C 的 signature 是 SHA-1。网上很多文章把 checksum 写成 CRC32实战中如果用 CRC32 修复加载时一样会报错。修复脚本用 Python 写最顺手import hashlib import struct import zlib def fix_dex_checksum(path): with open(path, rb) as f: data bytearray(f.read()) # signature: SHA-1 of everything after 0x0C sha1 hashlib.sha1(data[0x20:]).digest() data[0x0C:0x20] sha1 # checksum: Adler-32 of everything after 0x08 adler zlib.adler32(data[0x0C:]) 0xffffffff data[0x08:0x0C] struct.pack(I, adler) with open(path, wb) as f: f.write(data) fix_dex_checksum(dump_1.dex)高位字节序是 little-endianstruct 里面用I而不是I这两个写反也会出问题。此外壳在内存里展开 DEX 时可能调整过data_off、map_off这些偏移字段。如果 DEX 是被完整从紧凑内存区域 dump 的这些偏移通常没问题因为 DEX 内部所有 offset 都是相对文件头的。但如果涉及多段拼接、或者某些区域不是按原始文件顺序摆放的就需要逐一比对map_list里各个 section 的偏移是否都能在 file_size 范围内。碰到偏移错乱的情况我建议直接用dexdump报错信息辅助定位它会把section 超出文件末尾的偏移具体指出来比肉眼快很多。5.2 方法被抽空时的还原思路DexProtector 的抽取保护让人最头疼。dump 下来的 DEX 里类结构、字段签名、方法签名都在但关键方法的 code item 被替换成了 nop 或者无效指令。这种情况下直接反编译你会看到一片空白。还原的思路是运行时读取真实的 ArtMethod 数据。每个方法在 ART 里对应一个 ArtMethod 对象它的insns_字段指向实际执行字节码。通过 hook 目标类的构造方法或者任意调用点拿到执行上下文里的 ArtMethod 指针然后读取真实 code_item 的偏移和数据再把这段字节按 insns 的位置补回 dump 的 DEX 里。这种还原适合目标明确的情况比如只需要分析 3 到 5 个核心方法。如果整个 DEX 的方法都被抽取了手动还原的成本会非常大建议优先考虑用 frida 在类加载后的内存快照里搜索 code item或者针对壳的解密函数下断点从源头获取完整内容。补充一点部分高版本 DexProtector 在内存里回填完方法体之后会把原来的 nop 区域重新覆盖掉这时候直接扫描明文 code item 也能命中。扫描思路和在匿名段里扫 DEX 魔数类似只是目标从dex\n变成了 code_item 的固定字段组合。5.3 用 jadx 和 baksmali 验证脱壳结果脱壳完成后建议做双重验证不要只依赖一个工具。先用baksmali dump.dex如果命令能顺利跑完并生成 smali 目录说明 dex 头、map_list、class_defs 这些基础结构没问题。然后进 smali 目录里抽查目标方法看方法体里是不是真实指令而不是只有一行return-void。再用 jadx 拖进去看 Java 伪代码重点看字符串是否可读、方法调用链是否清晰。如果 jadx 解析时直接报dex 文件损坏或者类图全是异常多半是 dump 时有多段内容漏掉了回到第 3 章重新扫描和抓取。没有捷径可走。6. 实际踩坑清单与几个值得提前留意的细节6.1 几个高频崩溃场景与排查场景一frida-server 一启动App 直接闪退。大概率是壳的检测扫到了 frida 默认的线程名或者端口特征。优先验证自己的 frida 环境是否改干净其次再考虑加gum-js-loop和gmain线程名字过滤。场景二dump 出来的 dex 用 jadx 能解析但代码里大量方法体是空的。这是典型的抽取保护未还原。需要回到运行时做 ArtMethod 的注册读取把关键方法补上。场景三扫描过程出现大量误报或者 dump 出来的数据根本不是 dex 而是随机字节。检查启发式校验是否完整尤其要看 file_size 是否越界以及 string_ids_off 是否合理别省那几步。场景四DexProtector 的 native 线程在检测到 hook 后把解密区间的内存清零。这种需要更极端的处理最直接是 hookmemset和calloc返回前检查目标地址是否落在你记录的关键 dex 区间内是就直接跳过写入。6.2 DexProtector 版本差异对策略的影响DexProtector 的不同版本差异非常大。低版本用传统抽取加反调试就能扛多半时间高版本引入了更复杂的 native 虚拟机保护整个 dex 的加载逻辑被搬进了自定义指令解释器。如果你发现内存里搜不到完整的dex\n魔数很可能是最高版本那就是另一个更深的战场了需要先从 VMP 的指令解析器入手。面对版本差异我建议先做好壳指纹识别确认是哪一代产品再决定策略减少盲扫的时间成本。不同的壳对应的脱壳时机、匿名段分布规律都不同照搬老经验会很被动。6.3 安全边界提醒最后说一句经验之谈这套方法论本身是中性的用好了可以高效分析恶意样本、评估自家 App 的防护强度、参与 CTF 比赛但如果拿来做灰产或者盗版逆向翻车是迟早的事。商业加固的检测能力也在持续进化授权边界没搞清楚就硬上技术路线走不通不说法律和职业风险也很大。合规是第一前提其次才是技术乐趣。我实际跑下来最深的感受是面对 DexProtector 这类壳最高效的路径不是把整套壳从里到外逆向干净而是把检测面剥掉、把 dump 时机卡准。先把匿名段扫描脚本做成标配配合 hook 关键加载函数多轮抓取后再用 jadx 和 baksmali 双验证基本能覆盖绝大多数版本。逆向路上最大的敌人从来不是某个壳而是想抄捷径、不做检测面梳理的侥幸心理。把前置工作做扎实后面每一步都会顺很多。