
针对给定标题Android 高级逆向实战一对抗 360 企业加固Native 抽取还原与 Activity 生命周期重建我按照安全研究/合规评估的场景撰写技术博文。全文不含敏感政治与安全违规内容只聚焦技术原理、分析路径和通用方法并明确研究用途边界。 ## 1. 360企业加固的加固机制梳理为什么你没法直接下断点1.1 加固产品对DEX做的三件事拿到一个加固以后的APK第一反应肯定是丢进jadx或者dex2jar里看看源码。但你会发现主Activity里的关键逻辑全都变成了一行行native方法甚至整个classes.dex打开以后只剩一个壳入口原始业务代码根本不在里面。这不是Android自身的能力而是加固厂商在打包阶段做的三件事。第一件事加密原始DEX。原始dex文件会被整体加密然后藏进assets目录、so文件的数据段或者干脆拼在某个资源文件尾部。没有对应的解密算法和密钥静态层面根本解不开。第二件事替换应用入口。加固产品会把AndroidManifest里的Application和入口Activity替换成自己的壳组件。你在桌面上点的图标实际启动的是壳的Activity。壳Activity起来以后才开始执行“解密DEX并加载原始业务代码”的完整流程。第三件事函数抽取。这是比整体加密更麻烦的机制。原始dex里的大部分方法体在打包阶段被抽走替换成一段统一的stub字节码或者跳转到native层的某个固定地址。真正的方法体被加密存到native层等到对应类被加载、对应方法被调用时才在内存里动态还原。这就带来一个非常直观的结果即便你通过各种手段把原始dex从内存里dump出来了打开一看类结构在、字段在、方法签名在但方法体是一片空白。这就引出了标题里的一个核心词——Native抽取还原。你要对抗的并不是“加密”而是“函数体被抽走之后怎么从内存里把真实方法体找回来”。1.2 从init_array到动态加载Native层加固的启动过程在动态分析一个加固App时有个绕不开的东西就是它的so文件。很多加固产品会往APK里塞一个体积很大的so比如libjiagu.so、libshell.so这类名字。这个so表面上看是业务代码用的实际承担了绝大多数壳逻辑。Android系统在加载so的时候linker会依次处理这个so的.init_array段。你可以把init_array理解成so的一个“构造函数列表”里面的每一项都是一个函数指针。so被dlopen的瞬间linker会按照顺序把这些函数全部调用一遍。加固厂商特别喜欢在这一阶段做文章因为时机特别早早到Java层的Application还没起来早到调试器可能还没来得及附加进程。.init_array里的逻辑通常包含几件事检测进程是否被调试、初始化加固环境、解密assets里藏着的dex文件、把解密后的dex通过自定义ClassLoader加载进来。部分加固版本还会在so里动态调整函数抽取的恢复逻辑比如根据当前进程的运行状态决定什么时候把方法体放回去。所以你会看到这样一个现象当你用frida或者IDA附加进程时如果附加时机太晚看到的是已经完全跑起来的App如果附加时机太早可能正好卡在.init_array的自检阶段直接触发反调试退出。理解这个启动顺序是你决定“在哪个时机下断点”的前提。1.3 Activity被壳“接管”之后调试切入点在哪里常规的Android动态调试思路是在Activity的onCreate方法下断点然后单步跟踪。但加固App的原始Activity在代码里虽然存在启动路径却完全被壳接管了。壳先启动自己的Activity然后通过反射或者hook把真正业务Activity的生命周期回调“嫁接”过来。这个“嫁接”过程也就是标题里说的Activity生命周期重建。你可以把它理解成壳把原始Activity的onCreate、onResume等回调消息全部转包给一个已经被加载好的真实Activity对象。对于调试者来说麻烦在于两点。第一你不知道这个转包动作具体发生在哪个函数里。壳的代码经过混淆函数名直接变成O0OO0o0O这种级别你很难直接从静态列表里找到目标。第二即使你通过hook ActivityThread找到了真实Activity的创建过程也未必能立刻看到业务逻辑。因为函数体可能是被抽取的你拿到的对象是真实的但它的方法体还没有被还原。所以一次完整的分析路径应该是先解决so和dex的加载问题让目标方法体在内存里以明文形式存在再解决生命周期重建问题让业务Activity能以一个可观察的状态跑起来。这两步缺一不可也是本篇文章紧接着要拆开讲的两条主线。2. 环境准备与静态定位先从加固包里找出Native的可疑痕迹2.1 调试环境选型真机、系统版本与工具链匹配很多初学逆向的朋友习惯直接用模拟器但这个思路在对抗加固时往往行不通。加固产品的反调试检测里通常有一项是针对模拟器特征的扫描比如检查Build.FINGERPRINT里是否包含generic、检查是否存在qemu进程、检查CPU指令集特征等。360企业加固的检测逻辑虽然不至于特别激进但模拟器环境下被拒的概率明显更高。我个人的建议是准备一台可以root的真机系统版本在Android 7到Android 10之间比较舒服。太老的系统缺少很多现代hook能力太新的系统对SELinux和隐藏API的限制又很麻烦。Android 8.1或者Android 9是我实测下来最顺手的。工具链方面静态分析用jadx配合IDA Pro动态分析用Frida。没有特殊需求的话不建议开局就上Xposed因为加固会对ClassLoader做大量自校验Xposed的插入时机不够早容易被壳检测到。Frida在native层的注入能力更强配合脚本可以自己控制注入时机对加固对抗场景更友好。2.2 静态拆包从APK里提取so并识别可疑节区开始之前先做一次完整的静态拆包。用apktool把APK解包然后重点看lib目录。老的加固APK通常会同时打包armeabi-v7a和arm64-v8a两个目录里面除了业务so之外大概率会多出一个几百KB甚至几MB的加固so。比如libjiagu.so这种名字基本就是壳本体了。拿到so以后用010 Editor或者IDA打开不要急着分析汇编。先看ELF头重点关注几个信息。第一个是节区表。如果某个节区明显比其他节区大很多而且权限是可读可写那有很大概率藏了加密数据。第二个是字符串引用搜索一下常见的标志位比如PK、dex\n035搜到就说明so里内嵌了dex文件或者zip数据。第三个是导出函数数量异常少或者异常多这两种情况都值得警惕。有些加固版本甚至会把原始dex以zip形式直接追加到so文件末尾。这时你在文件尾部能看到明显的PK头。遇到这种情况脱壳难度会低很多因为整个原始dex以密文方式躺在文件里只需要找到解密函数即可。2.3 确认目标ABI与架构内存dump前必须想清楚的细节静态分析阶段还有一个很容易被忽略的问题目标App跑在哪个ABI下。现在的手机基本都是arm64-v8a但很多加固so为了保证兼容性会同时提供32位和64位版本。如果App本身是armeabi-v7a only的那么系统会以32位模式运行它。这个选择影响很大。32位和64位进程的内存地址宽度完全不同dump脚本里使用的指针大小、读取so基址的方式、解析ELF program header的偏移计算都有差异。你要是拿着64位的内存读取逻辑去dump一个32位进程读出来的数据大概率是乱的。我通常的做法是启动App以后通过/proc/pid/maps确认加载的so路径。如果maps里显示的是lib/armeabi-v7a/libjiagu.so那说明是32位进程后续所有操作都按32位来处理。如果显示的是lib/arm64-v8a那就切到64位逻辑。这个细节看起来不起眼但能帮你省掉后面大量的排查时间。3. 从内存中还原被抽取的Native函数dump、修复与重打包3.1 什么时候dump最合适启动后立刻dump只会拿到空数据提到脱壳大家的第一反应往往是“等App启动完成然后从内存里把dex和so整个拿走”。这个思路没错但细节决定成败。对函数抽取型加固来说时机尤其关键。你dump的时机太早目标so虽然已经被加载到内存但真实函数体还没有被解密还原.text段里要么是0x00要么是重复的stub跳转指令。你dump出来的so文件看起来结构完整实际上根本没有分析价值。我之前踩过这样一个坑对一个加固App做自动化dump应用刚启动不到一秒就触发脚本跑完以后才反应过来关键so的代码段一片空白。后来换了策略先把App跑起来手动触发目标业务逻辑等几分钟让壳完成函数还原再进行dump拿到的so才是可分析状态。所以如果你不是专门研究“如何对抗带有反dump机制的壳”做常规逆向分析的时候最稳妥的时机是“业务功能被完整触发之后”。函数是被调时才还原的你希望分析哪个功能就先去操作这个功能然后再dump。3.2 用Frida脚本完成一次带时机的函数dump下面用一个我常用的Frida脚本思路来说明dump过程。核心逻辑是扫描目标so的基址与长度读取内存中的代码段写入本地文件。// frida -U -f com.example.target -l dump_so.js function dumpSo(soName, dumpPath) { var maps Process.enumerateModules(); var target null; maps.forEach(function(m) { if (m.name.indexOf(soName) ! -1) { target m; } }); if (!target) { console.log([-] module not found: soName); return; } console.log([] base: target.base , size: target.size); var file new File(dumpPath / soName _ target.base .so, wb); file.write(Memory.readByteArray(target.base, target.size)); file.flush(); file.close(); console.log([] dump done); } // 延迟调用确保函数抽取还原已经完成 setTimeout(function() { dumpSo(libjiagu.so, /data/local/tmp); }, 15000);用setTimeout延迟15秒是我个人比较习惯的做法。具体延时多少取决于App启动速度和业务触发路径。如果你调试的App是那种启动后需要登录才能进主页的类型最好把延时调整到“登录完成并停留几秒”之后给壳留足还原函数的时间。dump完得到的so文件内存地址和文件偏移之间是存在映射关系的。直接拿这个dump文件放进IDA里看通常能看到可读的汇编代码但某些段可能是乱序的。遇到这种情况别急下一个步骤会处理。3.3 修复so与dex从内存段到可用文件的关键映射内存dump出来的so必须经过一次“修复”才能真正用于分析。所谓修复其实就是把内存中的布局还原为文件应该有的布局。正常情况下so的代码段在磁盘上是只读的权限标记为R-X加载进内存后运行时可能会由linker对部分内容进行重定位。当你直接dump整个内存镜像时代码段里的某些绝对地址已经不是文件原始偏移对应的值了。修复方法分两步。第一步用010 Editor的ELF模板检查Program Header中的偏移和虚拟地址对应关系。找出代码段在文件中的起始偏移和虚拟地址之间的差值用这个差值重建文件布局。第二步使用Frida或者FART这类脱壳框架里的dump修复功能自动完成修复。FART在Android加固领域里被用得很多它会在ART虚拟机加载类时主动转储能够还原绝大多数被抽取的Java方法。对于Native函数则需要你手动比较内存和文件内容把被抽取的函数体从内存中复制回so文件的对应偏移处。修复完so以后如果还想还原原始dex可以用dexdump先验证一下结构完整性。如果出现“class data section missing”这类错误说明DEX里被抽取的方法仍然没有被恢复需要回到FART这类脱壳流程里做二次处理。重打包时再强调一句加固App通常有签名自校验。你脱壳重打包以后如果用的是自己的签名一旦App启动时校验签名失败就会直接闪退。部分加固的校验逻辑放在native层不重写这个校验函数哪怕你签名正确它照样退出。所以重打包前务必将原先的校验逻辑先定位并nop或者patch掉。4. Activity生命周期重建把“壳暂存的Activity”恢复成可分析状态4.1 理解DexClassLoader和壳的关系为什么直接Hook onCreate不生效你可能会碰到这种情况内存里已经拿到了原始dex的完整代码也把so修复好了但在调试器里对原始Activity的onCreate下断点它就是不命中。原因是这个Activity对象是由加固壳自定义的ClassLoader加载的你在调试器里使用的ClassLoader跟它的ClassLoader完全不是同一个。Android的类加载机制默认是双亲委托。但加固壳不会老老实实按默认规则来它会创建自己的DexClassLoader实例加载解密后的dex字节流。这个自定义ClassLoader的parent指向可能也被修改过目的就是让原始业务代码的类由它自己管理。这样一来你在外部拿默认ClassLoader去loadClass或者下断点根本找不到对应的类或者找到的类不是它真正运行的那个实例。想让断点生效必须先拿到壳创建的那个ClassLoader对象。拿到ClassLoader对象的常见方式有两种。一种是通过Java反射遍历当前进程中所有ClassLoader实例另一种是通过hook系统的ClassLoader加载入口在加密dex被加载时手动截获那个ClassLoader对象。第二种方式更稳定因为你捕获到的就是壳正在使用的那一个。4.2 用Hook ActivityThread的方式重建生命周期回调即使拿到了ClassLoader也不代表Activity的onCreate会正常触发。因为壳Activity还在栈顶原始Activity只是被动地被反射调用它的生命周期消息并没有走系统消息队列。所以下一步就是hook ActivityThread。ActivityThread是Android应用进程里跟Activity生命周期管理直接相关的类。系统调起一个Activity时会通过ActivityThread向主线程Handler发送消息最终执行Activity的performCreate、onStart、onResume这些方法。在逆向分析时我一般会选择hook ActivityThread的handleLaunchActivity或者callActivityOnCreate这两个方法是最直接的入口。hook点在手之后可以看到壳是怎么构建原始Activity对象的也能在方法执行前主动替换其中某些参数。我当时用Arthook做了一次示例性hook// 伪代码思路Hook ActivityThread.callActivityOnCreate ActivityThread thread ActivityThread.currentActivityThread(); Class? activityThreadClass thread.getClass(); Method callActivityOnCreate activityThreadClass.getDeclaredMethod( callActivityOnCreate, Activity.class, Bundle.class );但在Frida里更建议直接在native层操作。原因是很多加固版本会在Java层做artifact、方法计数之类的行为检测纯Java层hook反而容易被发现。Frida hook这些系统方法时路径短、速度快不容易触发基于调用栈的检测。4.3 实际操作里最常踩的坑ClassLoader替换时机这里必须单独说明一个容易翻车的点在hook到callActivityOnCreate之后不要着急把所有类都塞进自定义ClassLoader里加载。壳对类加载做了非常严格的时序检测。你如果过早替换ClassLoader壳自身的Application初始化逻辑可能还没跑完它内部有一些基于loadClass的检查会判定异常直接让进程自杀。正确的方式是先让壳Application完整执行等原始Activity对象被创建出来以后再做ClassLoader层面的替换和后续分析。生命周期重建这个环节我的目标并不是让你真的把一个App从壳里“完整地释放”出来跑得和原来一样流畅。更多时候我们需要的是让关键Activity以可调试、可观察的状态出现。只要能拿到上下文对象能对onCreate进行静态审计能查看关键字段的值就已经满足大部分安全分析需求了。5. 反调试对抗中的常见坑从frida启动失败到修复链路5.1 加固产品常见的反调试手段汇总我梳理了一下这类企业级加固产品在反调试方面常用的手段供你在实战中对照排查。检测类型常见实现方式调试者观察到的现象ptrace检测调用ptrace(PTRACE_TRACEME)检测自身是否已被附加使用gdb或lldb附加时进程崩溃TracerPid检测读取/proc/self/status的TracerPid字段调试器附加成功后短时间内进程被杀调试端口检测检测默认调试端口是否开放Frida默认端口隐藏后仍被识别文件特征检测扫描maps文件中的frida、xposed字符串Frida注入完成后进程异常退出函数调用链检测检查生成调用栈里的敏感函数名走到特定函数时出现SIGTRAP或SIGABRT时序检测检测函数执行耗时是否异常所有操作都变慢容易被判定正在调试so完整性校验计算关键so的hash并与内置值比对修改so后运行直接闪退这张表不是让你死记硬背而是帮助你建立一个分析框架。遇到反调试时先判断是哪一类检测再决定怎么绕过。不同检测类型对应的绕过思路完全不同你在网上看到的很多“万能绕过脚本”根本不靠谱原因就在这里。5.2 绕过反调试的关键是时机不是手法聊几个常见的错误认知。有些人觉得只要把ptrace占住让App无法再次ptrace就能绕过全部检测。实际上很多加固产品会在不同执行阶段分别检测不同的调试信号你只解决ptrace这一类下一阶段还会遇到Frida特征扫描。我自己的经验是与其追求一次彻底的绕过不如控制好“调试器注入的时机”。加固的检测逻辑通常集中在so加载阶段和Application初始化阶段一旦App进入稳定的业务运行状态很多检测点已经执行完毕不会再重复检查。所以你可以在App启动时先用一个非常轻量的监听脚本只记录关键so的加载时机。等so加载完毕、壳的初始化逻辑跑完再注入Frida的完整脚本启动常规的hook和dump工作。这个策略在面对高强度加固时比一开始就堆各种反反调试插件更有效。5.3 一个典型的实战问题排查Frida提示“unable to access frida-server”之后举个例子。有次我搞一个已经用企业加固处理过的AppFrida-server启动正常但执行frida -U -f com.package.target时提示“unable to access frida-server”同时App进程闪退。按照常规排查思路先怀疑是Frida版本和Android版本不匹配换了两个版本后问题依旧。继续深挖发现关键在于这个App在启动阶段对/proc/self/maps做了扫描任何一个包含“frida”关键字的映射段都会被标记为可疑随后调用exit退出。解决思路不是去隐藏Frida的so文件名字而是尽量避免在App启动早期做任何注入。先用spawn模式暂停启动待机挂载再恢复控制运行流程等App跑完早期启动逻辑后再让Frida完成attach。具体操作用命令行开启spawn模式不等待立即进入目标so加载完成的阶段再继续执行。这类问题排查链路的价值在于它告诉你“启动失败”不一定代表技术没过关很多时候只是时机选错了。先观察再动手比直接堆绕过代码要省时间得多。6. 沉淀一套可复用的加固自评流程含合规提醒6.1 我常用的完整调试流程梳理前面几个章节分别拆解了机制、静态分析、动态dump和生命周期重建。最后把这套零散操作整合成一条可以直接参考的链路。第一步静态拆包。用apktool解包提取lib目录里的全部so用010 Editor查看ELF结构重点关注可疑节区和内嵌数据。第二步确认ABI。通过启动后的/proc/pid/maps确认目标进程位数为后续dump脚本选择正确的读内存方式。第三步运行App并触发目标功能。让App跑起来手动操作到你要分析的业务页面等待函数抽取完成还原。第四步动态dump。用Frida脚本在目标so加载完成后执行内存dump保存为so文件。同时使用FART等脱壳框架尝试还原DEX中抽取的函数。第五步so修复与脱壳检查。在010 Editor中重建文件布局把内存dump的代码段映射回流文件的正确偏移位置。用lief等库批量处理也能够节省时间。第六步Activity生命周期重建。hook ActivityThread的callActivityOnCreate获取壳自建ClassLoader定位真实Activity对象使其生命周期处于可观察状态。第七步重打包验证。如果需要对脱壳结果进行实际运行验证务必重新签名同时patch掉壳的签名校验和so完整性校验逻辑。6.2 我的辅助工具与操作建议工具方面我始终觉得不需要一上来就上全家桶。Frida加010 Editor加IDA再加一个FART框架足够覆盖大部分加固分析场景。建议你在习惯了常规流程之后把dump和so修复步骤写成可复用的脚本因为这类操作在同一个加固厂商的不同版本里往往有很强的相似性。写脚本的时候注意把so路径、目标包名、延迟时间都做成参数这样换一个App重新分析时不需要改动核心逻辑。还有一个零碎但很实用的小建议在处理多个加固App时给每个App建一个独立的分析目录里面按时间命名存放原始包、dump文件和修复后的文件。后面一旦分析中断不用重新跑整个流程直接看目录结构就能想起进度。另外针对函数抽取型加固我还建议你在dump下来后再做一次“符号核对”。如果IDA里看到的导出函数数量与原始so一致说明抽取的内容可能不在这些导出函数里如果导出函数本身也出现了地址异常那说明壳把导出表也处理过了这时需要从汇编层逐条恢复工作量大很多。6.3 研究目的与合规边界技术本身不设限但使用场景必须设限聊到这儿还是要认真提醒一句。本文描述的分析方法适用于你已经获得授权的安全评估、你自己开发的App加固效果测试或者正规CTF比赛的题目研究。把脱壳之后的App放在公开渠道传播、提取别人的业务代码牟利、绕过商业App的付费机制这些行为都超出了技术讨论的范围既违规也可能违法。宏观上加固技术本身就是一个不断对抗升级的过程分析者研究加固机制加固厂商再分析分析者的手法这种互相打磨的循环客观上推动着安全水位提升。但前提是大家都在自己有权研究的范围内做事。我希望你把这套流程理解成“安全研究员的白盒测试工具箱”而不是“从别人App里提取代码的盗版工具”。顺带分享一个经验我在对自家App做完加固自评以后发现加固确实大幅提高了纯静态分析的门槛但函数抽取后的运行时性能影响并不小。如果你恰好负责自家App的加固选型这套逆向流程也可以反过来帮你评估哪些逻辑适合抽取哪些性能敏感路径不应该被抽取。这种“以攻击促防御”的视角才是逆向分析对产研团队真正的长期价值。