news 2026/9/17 10:33:13

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
内存态匿名段分析实战:绕过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 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特征、检测线程名里有gmaingum-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掉fgetsread,当检测到目标文件是/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_sizeheader_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测试环境中使用。有能力攻破防护,也应该懂得边界在哪里,这是做安全研究的基本底线。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 10:28:58

Atlas记忆策略实战:让自定义Agent记住什么、忘掉什么

Atlas记忆策略实战&#xff1a;让自定义Agent记住什么、忘掉什么 【免费下载链接】atlas Source control for agents. Use multiple coding agents, track their changes and query them in one place 项目地址: https://gitcode.com/GitHub_Trending/atlas115/atlas Atl…

作者头像 李华
网站建设 2026/9/17 10:28:23

STM32与ESP-01S手写AT指令和MQTT报文接入云平台

STM32 和 ESP-01S 这套组合&#xff0c;做物联网课设、毕业设计、设备联网改造都绕不开。很多人拿到 ESP-01S 后第一反应是找现成云平台 SDK&#xff0c;或者直接套 MQTT 库&#xff0c;结果一换芯片、一换平台就抓瞎。我这里记录的是手打代码的方式&#xff1a;STM32 只负责串…

作者头像 李华