
很多搞Android安全的朋友第一次碰上DexProtector大概率都是从“这个APP的dex脱壳后怎么一跑就崩”开始的。商业加固工具这几年越做越重DexProtector算是一个典型代表——它不是简单把dex加密一下而是把Art虚拟机从启动到运行的整个链路都考虑进来了光在“dex加载”这一环就埋了好几道检测。而“内存态匿名段检测”就是其中很有意思的一道坎。这篇文章换个角度不吹“DexProtector无敌”也不空喊“干穿它”而是把它的加固思路、检测逻辑、以及对抗侧常见的“dex重组”思路放在一起拆开看。全程以原理分析和授权实验为主相关操作只建议在你有权限、有授权的前提下进行。先说清楚一个基础认知DexProtector这类工具保护的核心目标是dex文件。一个APP的dex文件包含了几乎全部业务逻辑谁拿到它谁就能逆向出代码。所以加固方案的第一步就是把dex加密或者抽取让静态拿到的apk里根本没有完整的dex。但app最终要运行加固后的dex必须被还原并加载进内存这就给了对抗侧一个机会——从内存中把dex dump出来。而DexProtector偏偏就在这一步做了大量文章“内存态匿名段检测”只是其中一条线。这篇文章我会从内存映射原理讲起再把检测逻辑和重组思路逐层展开。1. 先从“威胁模型”说起DexProtector到底在防谁1.1 加固工具眼中的攻击者画像很多刚接触加固的人会以为加固工具是在防“黑客”。这个说法太粗了。如果站在DexProtector这类商业产品的视角它的威胁模型其实非常明确一个拥有设备root权限、能抓包、能下断点、能读写进程内存的攻击者。这个攻击者通常已经拿到了apk本身甚至可以自由地在设备上做任何操作唯一缺的就是“完整可读的dex逻辑”。这个威胁模型决定了DexProtector的防御设计不是“不让进”而是“让你就算进来了也拿不到好东西”。所以你会看到它的方案里不仅有常规的文件加密还有防调试、防dump、防hook、防篡改甚至在运行时对dex内存区域做完整性校验。理解了“它防的是一个权限很高的攻击者”这一点后面看它为什么盯着内存态匿名段就顺理成章了。1.2 DEX从静态文件到内存态的旅程一个正常的Android app启动时dex文件从apk中提取出来由Art虚拟机通过mmap映射到内存中。这个映射通常是文件映射也就是说内存页背后有实际的文件在支撑内核在页面缺失时会从文件加载数据。这种映射在/proc/self/maps里长这样7f1c400000-7f1c600000 r--p 00000000 fd:0b 12345 /data/app/.../base.apk看到了吗后面是有名字的指向apk或者odex文件。这是加固工具判断“dex来路正不正”的一个天然依据。但当我们自己写代码把一份dex从内存中解密出来再用memcpy放到一块新分配的内存里再让Art去load它情况就完全不一样了。这块内存没有文件支撑内核只是给它分配了匿名页maps里对应的区域会显示成类似这样7f1c400000-7f1c600000 rw-p 00000000 00:00 0注意后面的00000000 00:00 0这表示一个匿名映射。对DexProtector来说这行信息本身就非常可疑。“一份合法的dex应该来自文件映射你这里却是匿名映射那大概率是某种动态加载或者dump后的数据。”这就是“内存态匿名段检测”最朴素的出发点。1.3 “匿名段”为什么会成为检测信号说到这你可能会问匿名映射本身在Android里很常见啊搞个JIT、放个堆内存不都是匿名映射吗为什么它能拿来做检测关键在于检测的“目标对象”不是匿名段本身而是“一份运行中的dex是否落在了匿名段里”。Art在把dex解析成OatDexFile或者DexFile的时候会记录下dex的内存地址范围。如果DexProtector的native层代码能拿到这些dex区域再逐一检查它们的映射属性就会很清楚地发现哎这个区域是匿名的来路不对。这就像小区保安盯的不是“有没有陌生人”而是“陌生人有没有住在业主登记的房子里”。匿名映射本身不违法但一份应该由文件加载的dex跑到了匿名段里那就说明中间一定发生了什么不该发生的事。所以DexProtector会在这个点上做定时巡检一旦发现异常轻则让app闪退重则触发假数据、自毁逻辑。2. 内存态匿名段检测是怎么实现的2.1 扫描时机启动期巡检与运行期轮询检测不能只在启动时做一次因为对抗侧完全可以在app完全起来之后再hook住Art的加载流程把重组的dex塞进去。所以DexProtector的做法通常是“启动时重点查运行期间歇查”。启动时重点查是在加固dex被解密、加载的瞬间检查当前Art运行时中已经注册的dex文件列表里有没有异常区域。运行期间歇查是后台开一个线程每隔几百毫秒扫一遍maps文件或通过Art内部接口遍历dex区域比对映射权限、文件来源等属性。这样的设计很直白但也很有用——大部分dump工具拿到dex后总需要一个加载动作只要加载动作发生在Art的常规路径之外就容易留下痕迹。2.2 检测维度的三个关键点抛开具体实现细节内存态匿名段检测核心就靠三个维度来判断第一个维度是“文件来源”。通过/proc/self/maps或者dl_iterate_phdr拿到一个内存区域的映射来源看它是/data/app/xxx/base.apk这种合法路径还是没有文件支撑的匿名区域。这是最关键的判断依据。第二个维度是“加载方式”。Art加载dex通常有固定路径——DexFile::Open、OatFile::Open、DexFileLoader这类接口。如果检测代码发现当前内存中的dex区域并非通过这些标准接口建立就说明加载方式是自定义的。DexProtector的hook点会埋在Art的关键方法上拦截OpenCommon、DexFile::OpenFile等函数记录每一个dex的来源。第三个维度是“运行特征”。包括dex区域的访问权限是r--还是rw-、数据段的布局是否完整、class_defs的偏移是否合理。如果一份dex是从内存中dump后直接重组的往往会有头部字段错乱、偏移对不齐等问题。这些特征虽然不是直接判定匿名段但会在综合评分里被拉出来作为异常加分项。2.3 一个简化的检测逻辑模型为了便于理解我把检测逻辑抽象成一段伪代码真实实现肯定更复杂但主体思路就是这个样子function checkDexRegion(addr, size): maps readMaps() region findRegion(maps, addr, size) if region is null: return ANONYMOUS_SUSPICIOUS if region.pathname : score 40 if region.perms ! r-- and region.perms ! r-x: score 20 if !isArtLoadedViaStandardPath(addr): score 40 if score 70: triggerProtection()这个模型解释了为什么“伪造maps”和“恢复映射属性”会成为绕过的核心方向。只要让检测函数在做判断时看到的信息足够“正常”分数就上不去检测自然就被糊弄过去了。3. “绕过”思路的底层逻辑不是骗检测而是重定义加载链路3.1 先想清楚绕过的边界很多人一听到“绕过”两个字就兴奋觉得是神仙打架。但做安全研究最忌讳的就是不讲边界。未经授权的绕过本质上是破坏别人服务的行为只有在你自己拥有的一台设备、自己开发的app、或者明确授权的测试环境里做研究才是有价值的。所以这一节讲的都是原理层面的思路枚举不提供现成脚本也不鼓励对任何你没授权的app动手。在这个前提下我可以负责任地说绕过内存态匿名段检测这件事难点不在“写代码”而在“是否理解了加载链路”。你要是能把Art加载dex的全流程走一遍绕过的思路自然就浮现了。3.2 思路A让检测函数看到一个“正常”的映射最直接的想法是既然检测函数在看maps那我就让maps里对应区域显示成文件映射。做法是先把dex文件写到磁盘上然后重新以文件映射的方式把这块区域挂载进来。但这有几个坑。第一个坑是文件写在哪里。你不能写到app自己的私有目录太显眼的位置因为DexProtector很可能检测这些目录的可写状态。第二个坑是Art加载dex以后会缓存很多跟文件路径、修改时间相关的信息光改maps不够还得保证文件存在且内容匹配。第三个坑是即便你伪造了maps运行时的定时自校验仍然可能通过对比文件hash和内存数据来发现你改过文件。所以这个思路看着简单实际操作限制很多。3.3 思路B在dex落地前动手把重组的dex写回原加载路径另一个思路是在DexProtector把解密后的dex加载进Art之前先一步把重组的dex内容替换过去。这个思路的巧妙之处在于它不跟检测打架而是让自己变成了“加载链路上合理的一部分”。具体原理是DexProtector必须在运行时把解密后的dex交给Art。而Art的加载入口是固定的如果你能通过Hook或者Inline Hook的方式在入口处截获数据把原始dex内容替换成你重组的dex内容那么后续的加载流程完全走的是合法路径。检测机制看到的是文件映射、标准加载接口、正常权限位。它自然不会觉得有问题。这里引出了一个关键概念——dex重组。因为你在入口处替换的不再是“原封不动的dex”而是从内存dump后修复、重组出来的新dex。这就对dex本身的结构完整度提出了很高的要求。3.4 思路CHook判定函数让检测“失明”第三种思路更直接找到检测代码的关键判定点直接让判定结果恒为“正常”。比如Hook住读取maps的那些函数让它们只返回经过过滤的内容或者Hook住最终的打分函数让分数永远低于阈值。这个思路的难度在于“找到关键判定点”本身。DexProtector是商业级的代码经过了混淆、加壳、反调试处理你没法直接在IDA里搜一个函数名就找到它。你只能通过动态调试、Trace、内存读写断点等方式一点点缩小范围。而且要小心很多加固工具会做双重校验——你Hook了A函数B函数可能也在做同样的事情或者A函数有完整性校验你改了它它就触发自毁逻辑。我把这几种思路放在一起对比一下思路核心动作难度风险伪造映射修改maps或文件映射属性中等定时自校验容易穿透原路径替换在Art入口替换dex内容较高需要掌握dex重组Hook判定函数修改检测逻辑返回值高反调试和完整性校验多从研究和学习的角度思路B最有价值因为它要求你把“dex加载”和“dex结构”这两块知识吃透。下面我就重点拆解dex重组技术。4. dex重组技术关键细节与实操流程4.1 dex文件结构回顾你重组时到底在动什么一个标准的dex文件开头92字节是header里面记录了一堆关键偏移file_size、header_size、string_ids_size/off、type_ids_size/off、proto_ids_size/off、field_ids_size/off、method_ids_size/off、class_defs_size/off、data_size/off。这些字段是Art解析dex的目录任何一个offs写错整个dex就废了。重组的过程本质上就是把dex的各段重新排列并把header里对应的offset同步更新。常见的内存dump得到的dex往往是“残缺版”的——可能是数据段缺失可能是class_defs里的数据被抽取到加固区也可能是header头被抹掉了一部分。所以重组的第一步永远是“体检”打开hexdump对照标准dex结构一项项看哪里对不上。4.2 重组的三个核心环节第一个环节是“修复header”。假设你从内存里dump出来一份dex首先要看file_size是否等于整个文件的大小再看header_size是否等于0x70然后是data_off和data_size。很多dump脚本会把data段跟header混在一起拷贝导致data_off指向的位置不对。修复方法很简单就是重新计算各段的实际位置将对应offset改成正确的值。第二个环节是“重建class_defs”。这是最复杂的部分。class_defs是dex的“班级名册”里面记录了每一个类的定义包括类名、访问标志、父类、接口、注解、静态字段、实例字段、方法列表。DexProtector的抽取加固通常会把这些信息从静态dex里抹掉等运行时再还原到内存中。如果你dump的时机不对拿到的class_defs可能是空的或者错位的。这时候就需要通过其他线索——比如method_id里的字符串索引、type_id里的类型描述——反向重建class_defs结构。这个过程非常繁琐但也最能体现基本功。第三个环节是“修复指令偏移”。dex里每条指令中的跳转偏移branch offset、异常处理偏移、字符串引用等都是以文件头为基准的绝对偏移也可能是指令间相对偏移。如果重组后这些偏移没有同步更新运行时会直接崩在“Bad offset”一类的错误上。为了不让你看得太抽象我把dex头里最关键的几个字段列一下字段偏移含义重组时的作用file_size0x20dex文件总长度验证dump是否完整string_ids_size/off0x38/0x3C字符串索引数量与偏移定位字符串type_ids_size/off0x40/0x44类型索引数量与偏移定位类型method_ids_size/off0x58/0x5C方法索引数量与偏移定位方法class_defs_size/off0x60/0x64类定义数量与偏移核心目录data_size/off0x68/0x6C数据段大小与偏移校验数据完整性4.3 实操示例用Python脚本做最基础的dex头修复在授权实验里拿到dump出来的dex后我习惯先写一个简短的Python脚本做头部校验和修复。下面这个示例只是最基础的版本不要指望它能处理加固后的复杂情况但思路是通用的import struct def fix_dex_header(path, output_path): with open(path, rb) as f: data bytearray(f.read()) # 读取header关键字段 file_size struct.unpack_from(I, data, 0x20)[0] header_size struct.unpack_from(I, data, 0x24)[0] data_off struct.unpack_from(I, data, 0x6C)[0] data_size struct.unpack_from(I, data, 0x68)[0] print(f当前file_size: {file_size:#x}, 实际文件长度: {len(data):#x}) print(fheader_size: {header_size:#x}, data_off: {data_off:#x}, data_size: {data_size:#x}) if header_size ! 0x70: print([!] header_size异常强制设为0x70) struct.pack_into(I, data, 0x24, 0x70) if file_size ! len(data): print([!] file_size与文件长度不一致尝试修正) struct.pack_into(I, data, 0x20, len(data)) # 这里只是演示实际还要校验data_off是否在有效范围内 with open(output_path, wb) as f: f.write(data) fix_dex_header(dump.dex, dump_fixed.dex)这种脚本一般只能解决“文件长度对不上”的问题。如果class_defs里的方法数据被抽取了或者指令偏移已经错乱就要用更专业的工具配合手动分析了。4.4 重组后如何验证重组完的dex能否被Art正常加载最简单的验证方式是把它放到一个测试app里用Art的dalvikvm命令单独执行dex中的某个类方法。如果它能跑通说明结构基本完好如果报错就要根据错误信息反向排查。比如java.lang.VerifyError往往说明指令流有问题NoClassDefFoundError说明class_defs索引有问题StringIndexOutOfBoundsException则可能是常量池里的字符串索引被改乱了。在我实际测试中dumped dex最常见的问题还是出现在“抽取方法体被还原后code_item的偏移没有同步修正”上这一类问题会让你忙活很久。5. 实操环境与授权实验一次完整的“加固评估”记录5.1 环境准备root设备、Frida、以及耐心做这种实验你需要一台root过的测试机或模拟器一套能跑Frida/Xposed的环境以及一份样本apk。我建议在样本选择上首选自己写的demo app把它用DexProtector加固后做测试这样既没有法律风险又能在出错时拿到最完整的日志。环境清单如下一台Pixel或任意可root的测试机模拟器也可以但有些加固工具对模拟器有指纹检测Frida 16.x frida-server一份自己开发、用DexProtector加固的demo appIDA Pro或Ghidra用于静态分析native层010 Editor或HxD用于hexdump分析dex结构我自己习惯用frida脚本先做一次“盲dump”找到进程中所有看起来像dex的内存区域把数据导出来然后用脚本查看文件头是不是dex\n035\0魔数。这一步在评估加固方案强度时非常有用能快速摸清对方在内存中保留了哪些暴露面。5.2 内存提取如何判断哪里是真正的dex区域dex在内存里不是孤零零的一块。Art会把header、string_ids、class_defs等区域分散在不同的内存块中管理。如果你直接遍历maps把所有可读区域都dump下来会发现大多数区域根本不算dex。判断的关键是找魔数dex\n然后验证它后面的file_size和实际可读区域是否匹配。我写过一个简化版Frida脚本原理就是遍历maps里的匿名可读区域逐个匹配64 65 78 0a即dex\n再读取0x20位置的file_size确认这个区域大小是否足够。这样可以快速定位出候选dex区域。不过实际dump时尽量不要只dump一个区域最好把相邻可读区域一起拉下来方便之后做class_defs补全。5.3 重组实操从dump到可加载dex的完整步骤我以一次对自写demo app的实验为例讲一下从dump到重组出来的完整步骤第一步启动app后等它完全运行起来用frida脚本枚举所有候选dex区域把内存数据保存为dump1.bin、dump2.bin。第二步用十六进制工具打开dump文件检查头部。常见情况是文件中确实有dex\n035\0但file_size很可能为0或者data段偏移不对。此时需要根据内存中周围的log和Art的解析行为来猜测正确的file_size。第三步如果文件头基本正常就用上一节的Python脚本修复头部。如果class_defs里很多内容为空就要对照dex结构手动重建。这个过程非常耗时间我通常先看string_ids里有没有关键类名再用baksmali尝试反编译把报错信息当成路标。第四步把重组好的dex重新打包到一个全新的apk里不加固直接安装运行看业务逻辑是否正常。如果正常说明重组成功如果不正常就去logcat里找崩溃堆栈定位是哪个类、哪个方法出了问题。5.4 时间开销与难度评估做一次完整的dumped dex重组在理想情况下大概需要几个小时。但如果是被DexProtector深度抽取的方法体可能耗上一整天也不一定跑完。我个人的体会是判断一个加固工具“难不难”不重要看它用了多少反调试更要看重它是不是每个方法体都抽取了、抽取的粒度细不细、是否会对dump进行主动干扰。DexProtector在这方面的防御确实做得比较扎实。6. 常见问题与排查技巧实录6.1 dump下来的dex打不开头部校验都过不了这是我遇到过最多的问题。原因多半是Art在加载dex时header被单独处理过或者内存中分散存储。你要做的是不要只看第一个dex\n把它当作唯一线索。可以试试扫描多个区域找出完整的header然后把data段按实际布局拼回去。还有个小技巧一些加固工具会把dex的file_size故意写错误导dump工具。这时候可以用/proc/pid/stat里的内存布局和运行时日志来反推实际大小。这个方法不是百分百可靠但至少能帮你缩小范围。6.2 方法指令跳错地方重定向失败重组后的dex如果出现验证错误或崩溃最常见的原因就是指令里的跳转偏移没有正确修复。因为dex的指令集不允许直接用绝对地址跳转而是基于当前指令位置的相对偏移。重组改变了指令位置就意味着所有branch、switch、exception handler的偏移都要重新计算。我自己常用的排查方式是把崩溃地址转换成文件偏移再回到hexdump里看当前位置的指令是什么。如果是goto或if-eqz这类跳转指令一步一步算过去看看它跳到的位置是不是一个合法指令的开头。6.3 app跑了三五分钟才崩溃这是定时自校验如果你发现重组dex在app启动后能正常跑很久但过了一阵子突然闪退大概率是DexProtector的定时自校验线程在工作。它会周期性地比对内存中的dex内容和原始文件hash一旦发现不一致就退出。应对思路是让dump出的dex内容与“原文件”在hash层面保持一致。这在实际操作中很难做到因为加固后的完整dex本来就不以文件形式存在。退一步讲在授权测试中我们通常会直接关掉自校验线程找到线程入口并suspend而不是试图去瞒hash。这需要更深的native层逆向功底也再一次印证了DexProtector的防御重心是“耗时间”而不是“拦不住”。6.4 绕过一层绕过不了一层多层检测叠起来才是真麻烦DexProtector的防护不是只有内存态匿名段检测这一层它还有防调试、防Hook、完整性校验、模拟器检测等。单独拿出某一层都能想办法“骗过去”但所有层叠在一起就不停地逼迫你必须在很短的窗口内完成dump和重组还要保持动静够小。这一点给我的实际感受是与其研究如何绕过不如认真研究它的检测原理顺势调整自己的代码设计和业务逻辑。对加固方案评估来说把“检测点在哪里”“检测逻辑怎么触发”“触发后有什么反应”搞清楚比单纯“干穿它”更有价值。7. 写在后面的个人体会这套实验做下来我最大的体会是内存态匿名段检测不是一个孤立的“小技巧”它背后反映的是Android运行时加载机制的一个基本事实——所有的逻辑最终都要变成内存里的数据而内存里的数据永远不可能完全隐形。加固工具能做的只是让这些数据变得难以辨认、难以提取、难以重放。而对抗侧能做的也只是在这个基础上一点一点缩小误差、补齐结构。如果你也在做类似的移动安全研究我的建议是不要贪快先把dex文件格式、Art运行时加载流程、内存映射机制这些基础啃扎实。工具可以帮你省时间但只有基础够牢遇到新思路、新检测点的时候你才不会慌。另外再强调一句所有实验都请在授权和合法的范围内进行多用自己写的demo app反复验证这才是能长期走这条路的安全姿势。