ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

内存态匿名段分析实战:绕过DexProtector检测并还原Dex

内存态匿名段分析实战:绕过DexProtector检测并还原Dex 拿到一个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 5Android版本12刷了Magisk。选这台设备是出于两个考虑一是Pixel的Linux内核比较标准frida的兼容性最好二是Magisk环境便于后续做root隐藏和模块注入。ROM这块有个细节要注意尽量不要用国产深度定制的ROMMIUI、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去spawnDexProtector几乎有一百种方式在几秒内发现你。最典型的就是检测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.qemu1它会直接判定环境异常。所以尽量用真机。# 启动定制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] ! bdex\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方法时才能扫到。我试过一个workaroundhook住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后应用秒退启动期完整性校验提前准备隐藏环境延迟dumpattach后应用闪退附着检测触发改用spawn或gadget模式扫不到dex魔数dex区域权限为不可读hook mprotect找可读窗口扫描时app崩溃壳发现Frida线程改名frida线程、换注入方案写在最后这次实战最深的体会是DexProtector这类商业加固的“硬骨头”不在于某个环节有多难而在于它的防御是联动式的你刚解决了反调试它还有反Frida你刚解决了反Frida它又把dex权限设成不可读。所以打这类壳心态上要做好多层面攻防的准备每过一个检测点都意味着离目标更近一步。还有一个经验想分享内存态匿名段分析之所以能成为突破口是因为它抓住了加固方案的“公共弱点”——无论壳怎么加密、怎么隐藏最终都要把dex明文加载到内存里执行只要这个事实不变内存扫描的思路就永远有效。遇到DexProtector的新版本不要急着去逆它的so先回头确认自己的隐藏环境和扫描策略是否做到了位很多问题其实出在更基础的地方。最后再说一句这篇文章的所有方法请只在你有权限、有授权的App测试环境中使用。有能力攻破防护也应该懂得边界在哪里这是做安全研究的基本底线。
返回列表