拿到一个DexProtector加固的样本,静态丢进jadx全是壳的入口,动态跑起来又各种反调试崩溃,这是很多做安卓逆向的朋友都撞过的墙。DexProtector这类商业加固并不是单纯把dex藏起来,它在内存里的布置、对调试器和hook框架的检测,都明显比普通加固高一个段位。这次实战我换了一条思路:不再跟它的检测逻辑硬碰硬,而是把重点放在内存态匿名段分析上,直接在进程的匿名映射里把解密后的dex找出来、还原出来,同时配合一套有效的检测绕过策略,让整个分析和dump过程能在不被壳发现的前提下完成。这篇复盘会把定位匿名段、扫描dex特征、绕过检测的完整过程和踩坑记录都展开讲讲,适合已经会基本Frida操作、想进一步打商业加固的安卓逆向学习者参考。
1. 项目背景:DexProtector 到底在防什么
1.1 加固后的应用长什么样
你拿到一个DexProtector加固的APK,用jadx打开classes.dex,会发现真正的业务代码根本不在里面,只剩一个壳的Application入口和几个native方法。它的原理说起来不算复杂:原始dex被加密后打包进assets或者藏在so里,应用启动时由native层负责解密,再在内存中加载真正的dex。
问题在于,这个“解密后加载到内存”的过程,恰恰是几乎所有脱壳机的切入点。普通的加固壳可能在/data/data/包名/目录下把解密后的dex落盘,或者用DexClassLoader明文加载,这种用frida-dexdump就能直接dump。DexProtector不太一样,它会自己做一套类加载和运行时逻辑,解密后的dex很多时候直接以匿名内存映射的形式存在,不落盘、没有文件路径,你在/proc/self/maps里看到的只是一段没有路径的rw-p区域。这就是标题里“内存态匿名段”说的事——分析目标不是一个文件,而是一段只存在于进程地址空间里的明文dex数据。
1.2 它的检测体系是怎么运作的
DexProtector对调试和注入的敏感度,在商业加固里属于比较激进的那一档。它内置了多层检测:检查/proc/self/status里的TracerPid判断是否被ptrace附加;扫描/proc/self/maps找frida、xposed、substrate这些特征;遍历/proc/self/task目录看有没有可疑线程;还会fork一个子进程做“哨兵”,定时检查父进程运行状态和内存特征,一旦发现异常就直接kill掉整个应用。
这里有一个很关键的认知:DexProtector不是一次性的启动校验,它是运行时常驻的。也就是说,你即使成功过了启动那一关,后面执行到某个业务方法时它仍然可能在监控。很多人dump到一半app自己崩了,不是Frida脚本写错,而是壳在运行期检测到了注入特征。
1.3 为什么选择内存态匿名段这条路线
面对这种壳,传统思路是找它的解密函数,hook住之后在内存里把dex捞出来。但DexProtector的native层做了很强的混淆和反hook,直接逆so找解密函数的成本非常高。
我的思路反过来:不去管它怎么解密,只关心结果——既然它最终要把dex加载到内存,那内存里就一定存在完整的dex镜像。我需要做的是先骗过它的检测,让应用以为自己是干净的,然后在它运行正常业务的时候,去遍历进程的所有匿名内存段,用dex的魔数作为特征把明文dex“捞”出来。这条路跳过了so层逆向,把问题简化成一个内存扫描匹配问题,这也是这次实战能走通的核心原因。
提示:所有绕过检测的手段,目的都局限在“在授权测试环境中让加固应用正常运行”,本质是安全研究中的攻防验证,不是用来做恶意用途的。这一点心里要有数。
2. 环境准备与工具选型
2.1 设备与ROM的版本搭配
先交代一下这次实战的环境基础配置,这些直接关系到后面的检测绕过方案能不能用。设备我用了一台Pixel 5,Android版本12,刷了Magisk。选这台设备是出于两个考虑:一是Pixel的Linux内核比较标准,frida的兼容性最好;二是Magisk环境便于后续做root隐藏和模块注入。
ROM这块有个细节要注意,尽量不要用国产深度定制的ROM(MIUI、ColorOS之类),因为它们本身就有大量的系统级服务和自启动管理,会干扰我们对进程状态的判断。而且DexProtector的root检测在很多国产ROM上会误报,这会让你分不清到底是壳检测到了root,还是ROM自身的问题。
系统版本也不是越高越好,Android 14开始对/proc/self/maps的读取限制变严了,很多老思路在14上直接失效。Android 12是比较平衡的选择,既能完整读取进程映射,又能用上较新的Linux内核特性。
2.2 为什么不能拿原版Frida硬刚
如果你直接装一个官方原版frida-server,然后frida -U com.target.app去spawn,DexProtector几乎有一百种方式在几秒内发现你。最典型的就是检测27042端口、检测/data/local/tmp/frida-server这个路径、检测maps里frida-agent的so特征、检测线程名里有gmain、gum-js-loop这类字符串。
我之前试过用官方server直接刚,结果是连spawn都完成不了,应用启动即闪退,Logcat里只有一行模糊的SIGABRT,根本拿不到有效信息。这就是为什么这次实战我没有用原版frida去硬碰,而是从一开始就按“隐藏注入”的思路搭环境。
其实还有一条更稳的路是脱离frida,自己写一个ptrace注入器,把所有检测点自己处理。但那样开发量大,而且调起来非常慢。对大多数分析场景来说,定制版frida加合理规避已经够用。
2.3 落地一套隐藏的注入环境
我的隐藏方案分三步走,每一步都对应DexProtector的一个检测维度:
第一步,替换frida-server。把官方server重命名成surfaceflinger这类看起来像系统进程的名字,丢到/system/bin目录下,用-l参数把默认端口从27042改成一个高位端口,比如6666。这样端口扫描和默认路径检测就都失效了。
第二步,处理root特征。DexProtector的root检测不止check su文件,它会执行which su、读/system/app/Superuser.apk是否存在、检查Magisk的包名。我用Magisk的DenyList功能,把目标应用加进去,让它在读取root相关路径时拿到的是隐藏后的结果。
第三步,也是容易被忽略的一步——时区与系统属性的一致性。壳会把当前进程的某些系统属性值和它自己维护的“安全基线”做对比,如果你的系统有明显的模拟器特征(比如ro.kernel.qemu=1)它会直接判定环境异常。所以尽量用真机。
# 启动定制frida-server的示例命令(伪代码示意) adb push frida-server /system/bin/surfaceflinger adb shell chmod 755 /system/bin/surfaceflinger adb shell "surfaceflinger -l 6666 &" adb forward tcp:6666 tcp:6666 # 连接时指定端口 frida -H 127.0.0.1:6666 -f com.target.app这套环境搭完,至少能保证应用不会在启动阶段就被秒杀,但还没到能安全扫描内存的程度,因为壳的运行时检测还在。更细致的绕过操作我放到第4部分专门讲。
3. 内存态匿名段的定位与Dex还原
3.1 先从 /proc/self/maps 认识匿名段
/proc/self/maps是Linux内核暴露给用户的进程内存布局表,每一行描述一块连续的内存区域,包含起始地址、权限、偏移、设备号、inode和路径名。对Android逆向来说,最有价值的信息是看每块区域的“来源”。
正常的dex文件如果是从磁盘加载的,maps里会显示对应的apk路径或odex路径。但是如果一段内存没有对应的文件路径,inode是0,权限是rw-p,那它就是匿名映射。匿名映射最常见的来源包括:进程堆内存、mmap分配的匿名内存、JIT编译产生的代码缓存,以及——加密壳加载解密后dex的缓冲区。
这就是DexProtector的“藏身处”:它希望把dex伪装成普通堆内存,不留下文件路径,让静态分析无从下手。但再伪装,dex的文件头结构是变不了的,这给了我们定位的线索。
3.2 扫描dex魔数与区域过滤
Dex文件开头有一个固定的8字节魔数,通常是64 65 78 0A 30 33 35 00,对应ASCII就是dex\n035\0。不同dex版本结尾的“035”可能会是“036”“037”“038”,但前4字节64 65 78 0A(即dex\n)是雷打不动的。
我的扫描策略是:先枚举进程所有内存范围,然后只保留符合条件的匿名段,再在匿名段里扫描dex\n魔数。命中之后,读出整个内存段,按dex header里的file_size字段截取,就能得到完整的dex文件。
// frida脚本:定位匿名段中的dex function findDexInAnonRanges() { var ranges = Process.enumerateRanges({ protection: 'r--', coalesce: true }); var dexMagic = "64 65 78 0a 30 33 35 00"; var results = []; ranges.forEach(function (range) { // 过滤掉有文件路径的区域,只保留匿名段 if (range.file && range.file.path) { return; } try { var matches = Memory.scanSync(range.base, range.size, dexMagic); matches.forEach(function (match) { // 读取dex header,解析file_size var header = match.address.readByteArray(0x70); var buf = Buffer.from(header); var fileSize = buf.readUInt32LE(0x20); if (fileSize > 0 && fileSize < range.size) { results.push({ address: match.address.toString(), size: fileSize }); } }); } catch (e) { // 有些区域读取时会报错,跳过即可 } }); return results; } rpc.exports = { finddex: findDexInAnonRanges };这里有一个很重要的过滤逻辑:coalesce: true会把相邻且权限相同的内存区合并,这可以减少扫描次数。但也要注意,DexProtector分配的dex缓冲区可能被分割成多块,或前后有其他内存数据贴在一起,所以扫到魔数后不能想当然地认为整块区域都是dex,必须用header里的file_size做精确截取。
3.3 从碎片中还原完整Dex
扫到魔数只是第一步,真正麻烦的是dex在内存里不一定以连续完整的形式存在。DexProtector有时会把dex分块加载,或者在一个大的匿名段里混入了解密用的临时缓冲区和dex本体。
我的处理方法是:扫到魔数后,先读file_size字段,确认长度是否与运行时加载的dex一致。如果一致,直接按地址和大小dump即可。如果发现file_size比实际匿名段小很多,说明dex后面还有别的数据,只截取前file_size字节就行。
另外一个实战技巧是,用dex的文件结构做二次校验。比如string_ids_size(偏移0x38)和type_ids_size(偏移0x40)是否合理,class_defs_size(偏移0x60)是否大于0。如果这些字段读出来是乱码或天文数字,说明魔数可能是JIT编译中间产物里碰巧出现的,不是真的dex头。
# 用python把dump下来的字节校验并保存为dex文件 import struct def validate_and_save(data, path): if data[0:4] != b'dex\n': raise ValueError("not dex magic") file_size = struct.unpack('<I', data[0x20:0x24])[0] string_ids_size = struct.unpack('<I', data[0x38:0x3C])[0] class_defs_size = struct.unpack('<I', data[0x60:0x64])[0] if file_size > len(data) or string_ids_size > 0xFFFF or class_defs_size == 0: raise ValueError("invalid header field") with open(path, 'wb') as f: f.write(data[:file_size])刚开始做这个项目的时候,我犯过一个低级错误:扫到魔数后直接把整个匿名段dump下来就丢给jadx,结果jadx直接报错。后来才意识到是没按file_size截取,把dex后面的堆内存垃圾也一起存下来了。这个细节看起来小,却能卡住很多人。
4. 检测绕过策略的完整实操
4.1 反调试检测过法
前面说过,DexProtector会通过检查TracerPid来判断有没有调试器。这个检测在native层实现,绕过的思路不是去改内核,而是让壳“看不到”TracerPid的变化。
第一个简单有效的方法是走/proc/self/status的读取重定向。用Frida hook掉fgets或read,当检测到目标文件是/proc/self/status时,把返回内容里的TracerPid:\t1234替换成TracerPid:\t0。这个思路实现起来不难,但要注意壳可能不止用libc的fgets,它可能直接调用read系统调用,或者通过__android_log_print的底层拿内容,所以hook点要全。
第二个思路更偏底层:在native层用inline hook把ptrace调用直接改掉,让壳里的反ptrace检测失效。这个适合壳已经检测到Frida的场景,属于防御性兜底。
TracerPid: 1234 <- 被检测就是因为它 TracerPid: 0 <- 让壳看到的样子4.2 Frida特征检测的绕过
DexProtector对Frida的检测,主要集中在三点:frida-server的默认端口、frida-agent在maps里的路径、frida运行时的线程名。
端口检测我用改端口解决。maps路径检测靠前面说的重命名加移动路径解决。线程名检测则需要用Frida的Thread.setName或者定制编译把gum-js-loop改成别的名字。还有一个容易被忽略的特征是/data/local/tmp目录下的文件列表,如果壳发现这个目录里有不合法的可执行文件,它会预警,所以要确保frida-server不放在这个默认目录。
另外,如果壳已经检测到Frida但还没有立刻崩溃,而是进入“观察模式”,这时候不要急着继续操作。先挂起脚本,等几秒,看进程是否稳定。如果稳定,再继续。如果又崩了,说明壳的实时检测还在起作用,需要进一步patch检测点。
4.3 注入时机:spawn还是attach
绕过检测的另一个关键是注入时机。用frida -f的spawn模式,应用在启动早期就会被挂起,这时候壳的检测逻辑可能刚初始化,很多检测还没开始跑,确实是最理想的注入窗口。但spawn模式也有风险:如果你的定制环境没做好,壳的Application入口在首次执行时就会做完整性校验,比运行期检测更严格。
attach模式是在应用已经运行起来之后再附加,这时候壳的检测线程已经在循环执行了,附加动作本身就可能触发检测。好处是你对环境的可控性更强,可以先把业务跑起来,稳定后再附加。
这次实战我推荐一个混合策略:先spawn启动,但不在启动阶段执行任何dump操作,只是让Frida环境静默挂在进程里。等应用进入主界面、壳的启动期校验结束后,再用setTimeout延迟执行内存扫描。这样既避开了壳启动期的强校验,又能拿到运行稳定后的完整内存布局。
// 延迟加载扫描逻辑,避开启动期强校验 setTimeout(function () { var dexList = findDexInAnonRanges(); send({ type: 'dex', data: dexList }); }, 15000); // 建议根据应用启动速度调整4.4 动态patch检测函数
如果壳的检测逻辑是Java层写的,可以用Frida直接hook对应的Java方法,把返回值改成安全值。这个方式在新版DexProtector上越来越少见,因为它的大部分检测都下沉到native层了,Java层只剩几个通知入口。
native层的检测函数patch要麻烦一些,得先找到检测函数地址,然后改写它的返回逻辑。有一个取巧的办法:很多检测最终都要向一个“上报”函数提交结果,比如raise(SIGABRT)或_exit()。你可以直接hook这些最终出口,让检测到了也“报不出去”。
var raisePtr = Module.findExportByName(null, "raise"); Interceptor.attach(raisePtr, { onEnter: function (args) { if (args[0].toInt32() === 6) { // SIGABRT console.log("block SIGABRT from shell"); args[0] = ptr(0); // 改成正常信号 } } });但是这种“拦截出口”的方案只适合定位分析,不适合长期运行。壳很可能有二次校验,或者用syscall直接调tgkill杀进程,绕过了libc的raise。所以我通常把patch检测函数当成临时手段,核心目标还是快速把dex dump出来。
5. 踩坑实录与问题排查
5.1 dump出来的dex无法解析
这是我遇到最多的问题,没有之一。表现为:dump下来的文件用jadx打开报错,或者打开后类很少、全是壳的类。
第一种情况是没按file_size截取,混入了其他内存数据。解决方法是严格按header尺寸截取,并用Python脚本做二次校验。
第二种情况更隐蔽:你可能dump下来的确实是dex,但它是DexProtector的“代理dex”,里面只有很少的类,真正的业务逻辑在另一个匿名段里。DexProtector会把类拆到多个dex或分块加载,一个应用里可能存在多个dex镜像。这时候不要只扫一次,应该扫描所有匿名段,把每个命中魔数的区域都dump下来,逐一比对。
还有一个细节:有些版本的DexProtector在解密dex后,会把dex header中的部分字段改成0,给静态解析制造障碍。这种“头修复”问题,需要手动补全file_size、header_size等字段,或者用baksmali这类工具强制解析。
5.2 一附加就闪退
如果你用attach模式一上去就闪退,基本可以断定是壳的附着检测起作用了。它可能通过/proc/self/stat里的进程状态变化、或者监控/proc/self/task目录发现新增的注入线程。
这种场景我建议切换到spawn模式,并且在spawn后的前几秒内不做任何操作,让应用完全信任当前环境。另外,确认你的定制frida-server没有监听默认端口,壳对端口的检测比我们想象的更直接——它会connect一下27042,如果握手成功就立刻自毁。
我曾遇到一个极端情况:壳不是检测27042,而是把所有高位端口都尝试connect一遍。这种只能换用gadget模式,把frida-agent以库的形式直接注入到APK里,不走server,从源头规避端口检测。
5.3 匿名段扫不到dex
还有一种情况是你的扫描脚本在跑,但就是扫不到dex\n魔数。先检查是不是权限问题,Process.enumerateRanges('r--')只能扫到可读范围,如果壳把dex放在r-x(可读可执行)的内存段里,r--就匹配不到。
更常见的原因是——壳在Android 12以上用了mprotect把dex区域的权限改成了---(不可读),然后只在需要执行时才临时改成可读。这种行为级反dump让静态扫描失效,必须在它执行某个dex方法时才能扫到。
我试过一个workaround:hook住mprotect,当检测到有匿名内存段被改成可读时,立刻在旁边扫描dex魔数。这个方案成功率更高,因为它恰好在你需要的时间窗口内触发。
var mprotectPtr = Module.findExportByName(null, "mprotect"); Interceptor.attach(mprotectPtr, { onEnter: function (args) { // 参数3是权限位,0x1表示PROT_READ if (args[2].toInt32() & 1) { scanRange(args[0], 0x10000); // 扫描这段虚拟内存 } } });5.4 常见问题速查表
| 现象 | 根因 | 解决方案 |
|---|---|---|
| dump后jadx报错 | 混入了堆垃圾数据 | 严格按file_size截取并二次校验 |
| jadx能打开但类很少 | 只dump了代理dex | 扫描所有匿名段,逐一比对 |
| spawn后应用秒退 | 启动期完整性校验 | 提前准备隐藏环境,延迟dump |
| attach后应用闪退 | 附着检测触发 | 改用spawn或gadget模式 |
| 扫不到dex魔数 | dex区域权限为不可读 | hook mprotect找可读窗口 |
| 扫描时app崩溃 | 壳发现Frida线程 | 改名frida线程、换注入方案 |
写在最后
这次实战最深的体会是,DexProtector这类商业加固的“硬骨头”不在于某个环节有多难,而在于它的防御是联动式的,你刚解决了反调试,它还有反Frida;你刚解决了反Frida,它又把dex权限设成不可读。所以打这类壳,心态上要做好多层面攻防的准备,每过一个检测点都意味着离目标更近一步。
还有一个经验想分享:内存态匿名段分析之所以能成为突破口,是因为它抓住了加固方案的“公共弱点”——无论壳怎么加密、怎么隐藏,最终都要把dex明文加载到内存里执行,只要这个事实不变,内存扫描的思路就永远有效。遇到DexProtector的新版本,不要急着去逆它的so,先回头确认自己的隐藏环境和扫描策略是否做到了位,很多问题其实出在更基础的地方。
最后再说一句:这篇文章的所有方法,请只在你有权限、有授权的App测试环境中使用。有能力攻破防护,也应该懂得边界在哪里,这是做安全研究的基本底线。