尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

360企业加固实战:Native抽取还原与Activity生命周期重建全记录

360企业加固实战:Native抽取还原与Activity生命周期重建全记录 先说结论这次实战打完后我对“企业级加固为什么难搞”有了非常具体的体感。360企业加固的难点不在于它有多少反调试而在于它把Dex方法体和Native执行路径绑在了一起——你单纯dump Dex拿不到完整代码单纯分析so又对不上Java层调用关系。只有两条线同时走把Native抽取还原和Activity生命周期重建一起解决一个可分析、可调试、可复现的App样本才算真正拿到手。我写这篇是给有一定Android逆向基础、但没怎么碰过商业加固的朋友做参考。文章会按我的实际处理顺序来先识别加固方案再讲Native抽取的原理然后给完整的so内存转储与修复操作最后解决Activity生命周期断链的问题。所有操作都以安全研究、授权测试为前提别拿线上别人的App练手出了问题自己扛。1. 加固方案识别与整体对抗思路1.1 怎么确认目标用了360企业加固拿到一个App先别急着上Frida和IDA第一步永远是确认它到底用的哪家加固、哪个版本。不同方案之间的对抗思路差异很大乱试会浪费大量时间。360企业加固的特征在静态和动态两侧都比较好认我一般用下面几个信号交叉验证。静态解包阶段用jadx或apktool打开APK重点看这几处lib目录下存在libjiagu.so、libjiagu_a64.so以及带数字编号的libjiagu_xxxx.so基本就是它classes.dex体积异常小整个文件打开后只有几个类且核心类名是com.stub.StubApp或类似结构AndroidManifest.xml里application的android:name指向了StubApp入口Activity也是壳的代理类assets目录下通常有加密的数据文件命名可能是.dat、.bin或没有扩展名体积较大放着原始Dex的加密数据。动态侧更好验证。启动App后用Frida打印加载的so列表如果出现libjiagu相关模块同时进程目录下出现带SecShell生成的文件就可以直接锁定。日志里搜“jiagu”“secneo”这类关键字也会有一堆线索。需要注意企业版和免费版在抽取强度上有差别。免费版更多是Dex整体加密动态加载企业版才是真正的“类抽取Native还原”。本文针对的是企业版。1.2 动态dump优先于静态脱壳机很多新手上来就会用现成的脱壳机一键拿到Dex然后开开心心用jadx分析。但对企业版加固这条路大概率是死的。脱壳机拿到的Dex里方法体被抽空类和方法签名都在但CodeItem是空的反编译出来所有方法都是return void。为什么会这样因为这类加固在Dex加载流程里做了手脚原始字节码不以完整形态落在文件里而是在类加载或方法被调用的瞬间由Native代码从加密数据里解密恢复。恢复后的CodeItem是直接写进内存里的DexFile结构的静态文件里永远是“残缺”的。所以整体思路必须从“从文件里还原”切换到“从内存里还原运行时状态”。我自己的路线是双管齐下路线A在ART运行时层面dump Dex拿完整CodeItem适合纯Dex抽取型路线B针对Native抽取做so的内存镜像转储和方法级还原适合Dex与Native混合型。360企业版的麻烦在于它两者混用。一部分敏感方法抽到Dex里动态恢复另一部分直接抽到so里用自研解释器执行。所以只走A路线会看到一堆私有指令只走B路线又对不上Java层调用关系。正确做法是先走B把Native侧的执行入口摸清楚再回头用A补全Dex。1.3 对抗的最终目标是什么我们做逆向的最终目标不是“脱壳成功”这个动作本身而是拿到一个可以被正常分析的目标程序形态。对360企业版来说这个形态包含三件事Dex层所有类的方法CodeItem完整可读Native层so的代码在IDA里能看到清晰逻辑关键函数能与Java层对应运行层Activity生命周期正常触发App能像脱壳前一样跑起来。这三件事缺一不可。很多人脱完壳发现App能装了但一跑就白屏崩溃问题多半出在第三件事上。所以我把Activity生命周期重建当作整个对抗链路的最后一环而不是“附加题”。2. Native抽取原理剖析2.1 先弄明白DEX方法抽取干了什么要理解Native抽取得先把ART运行时里方法调用的基础机制讲清楚。一个普通Java/Kotlin方法在编译后对应DEX文件里的一个CodeItem结构。它记录了寄存器数量、入参大小、指令数组、异常处理表等信息。ArtMethod是运行时里的方法对象内部有一个字段指向CodeItem。当ART执行到一个方法时会根据ArtMethod的入口点决定走解释执行interpreter还是编译执行AOT/JIT。抽取加固的核心动作就是把这个CodeItem在文件里抹掉或者替换成无效指令。运行时一旦有方法被调用Native代码会先拦截从解密数据中找回原始CodeItem临时写回Dex在内存中的对应位置再把控制权交还给ART。关键点在于只有被调用过的方法在内存里才有完整CodeItem。没被调用过的方法内存里永远是空壳。这给逆向带来的矛盾就是——你想分析逻辑就必须让App充分跑起来但很多逻辑在逆向阶段不会自动触发需要主动调用或触发对应功能。我处理这个问题的办法是挂ArtMethod::Invoke的Hook把所有被调用方法的类名、方法名、CodeItem地址全部打印出来。跑完一轮业务流程后根据日志找缺失项。如果某些方法始终没出现就说明它要么没被业务使用要么调用路径被刻意隐藏了。2.2 360企业加固的Native抽取层特点360企业版与其他加固方案最明显的差异在Native侧的自研解释器。它会把一部分关键方法体从标准Dex字节码转换成私有格式执行时由so里的解释循环逐条读取。这意味着即便你完整dump了Dex看到的也只是私有指令一堆让人摸不着头脑的数据。但这不代表无解。为了兼容ART的Method调用规范厂商必须在某个时机调用ArtMethod相关接口来设置入口点或者把CodeItem塞回DexFile数据结构。只要能定位到这个时机并在这个时机之后立即接管就能拿到“被恢复后的状态”。我常用的手段是在art_quick_invoke_stub或ArtMethod::Invoke下Inline Hook。这是一个非常关键的位置因为无论解释执行还是编译执行最终都会经过这个入口。挂上Hook后可以直接读取方法的ArtMethod指针进而拿到类名、方法名、方法起始地址、CodeItem地址。把这些信息汇总就能构建一张“方法地址映射表”后续还原和验证全靠它。2.3 为什么静态dump so会失败新手经常犯一个错把libjiagu_xxx.so从APK里解出来扔进IDA期望看到抽取逻辑。结果反汇编出来全是数据函数凌乱完全没法分析。原因是内存中的so和文件中的so根本不是同一份代码。so被加载进内存后经历了几个关键变化load bias动态链接器把so映射到某个随机基址而不是文件的链接地址GOT表被填充所有导入函数的真实地址被写入GOT表重定位被应用代码里的绝对地址引用和相对偏移引用被修正加密/解密逻辑加固方案可能会在运行时解密.text段或者用匿名映射覆盖部分代码页。所以正确思路是转储“加载完成后”的so内存镜像而不是解析静态文件。镜像里的.text段是解密后的明文GOT表是真实地址重定位也已经应用完毕。然后再根据ELF结构把它修复成一个可以静态分析的文件。3. Native抽取还原实操3.1 环境准备root环境 Frida 合适的分析工具在做任何还原工作之前先把环境配置到位。我试过在一台没root的模拟器上折腾光绕权限就花了一晚上得不偿失。推荐下面的组合Android 8到Android 10之间的真机或模拟器必须root建议用Magisk或对应模拟器的root方案Frida 15frida-server版本与PC端frida要严格一致否则会报版本不匹配IDA Pro 8.x用于静态分析和远程调试010 Editor或Hex Fiend用于手动查看和修改ELF字节Python 3 pyelftools用于批量编写修复脚本一个用来做对照实验的Demo App自己也用360企业版加壳后面验证还原结果时能省很多事。有反调试的情况下优先在模拟器上先处理基础检测再上真机。很多反调试只会在so初始化时跑一次利用这个特点可以先让App正常启动等初始化完成后再延迟attach能绕开大部分检测。提示如果你发现Frida attach进去后进程立刻退出先去检查/proc/pid/status里的TracerPid以及是否有线程在轮询检测端口。不要一上来就上反Frida框架很多时候延迟注入就够了。3.2 用Frida把完整so内存镜像捞出来so内存转储是整个Native还原里最基础、也最容易做错的一步。我的脚本思路是解析ELF Program Header把所有PT_LOAD段从内存中读取出来按文件偏移拼成一个完整文件。为什么不能直接从模块地址开始到结束memcpy因为so在内存中有多个PT_LOAD段每个段的文件偏移与虚拟地址映射关系不同。直接连续读取会把段间空隙的冗余数据也读进来文件结构错乱后续修复反而更麻烦。我在实际项目中用的Frida脚本核心逻辑如下function dump_so(moduleName, outFile) { var mod Process.findModuleByName(moduleName); if (!mod) throw new Error(module not found: moduleName); var base mod.base; var ehdr base.readByteArray(64); var view new DataView(new Uint8Array(ehdr).buffer); var e_phoff view.getUint32(28, true); var e_phentsize view.getUint16(42, true); var e_phnum view.getUint16(44, true); var chunks []; for (var i 0; i e_phnum; i) { var phOff e_phoff i * e_phentsize; var p_type view.getUint32(phOff, true); if (p_type ! 1) continue; var p_offset view.getUint32(phOff 4, true); var p_vaddr view.getUint32(phOff 8, true); var p_filesz view.getUint32(phOff 16, true); var p_memsz view.getUint32(phOff 20, true); var memStart base.add(p_vaddr); var mem memStart.readByteArray(p_memsz); chunks.push({ offset: p_offset, data: new Uint8Array(mem) }); } var maxOff 0; chunks.forEach(function (c) { maxOff Math.max(maxOff, c.offset c.data.length); }); var out new Uint8Array(maxOff); chunks.forEach(function (c) { out.set(c.data, c.offset); }); var f new File(outFile, wb); f.write(out.buffer); f.close(); console.log(dumped to outFile , size maxOff); }这段脚本读取ELF头中的段表信息遍历所有PT_LOAD段按文件偏移写入新文件。需要注意几个细节p_memsz大于p_filesz时说明段内有BSS或者运行时才分配的空间直接读取p_memsz长度即可多的部分是运行时数据保留下来不影响分析如果App是64位进程ELF头字段位置和32位略有差异脚本里用的是64位偏移32位的话要对应调整转储时机很重要一定要在执行完关键业务、所有需要分析的代码都被调用过之后再dump否则.text段里是密文拿下来也没用。3.3 修复ELFIAT与重定位的三种情况dump下来的so多半不能直接被IDA正常加载。最常见的问题是导入函数表为空、函数列表不完整、或者反汇编结果混乱。根据我的经验可以把修复情况分成三类按不同类型处理。第一类是“静态基址一致”。如果so在编译时用了固定基址通常是没有开启PIE的旧so并且内存加载地址恰好等于基址那dump出来的镜像和原始文件差不多直接IDA打开就行。32位老App里偶尔能遇到。第二类是“基址偏移但GOT完好”。现在App基本都开了PIEso的加载地址不是链接时的基址但GOT表被动态链接器填充完毕里面全是真实函数地址。这种情况下需要把dump出来的so里的load bias计算出来然后调整ELF头或IDA加载选项让IDA能够正确解析。计算load bias的方法是在/proc/pid/maps里找到so模块的起始地址再在ELF头里找到第一个PT_LOAD段的p_vaddr两者相减就是load bias。比如模块基址是0x7f1234560000第一个段的p_vaddr是0x1000那load bias就是0x7f123455f000。IDA加载时把这个值填进manual load bias或者把ELF程序头里所有p_vaddr统一减去这个差值就能正常映射。第三类是“代码区域被覆盖或加密”。这种情况比较复杂.text段在运行前是密文运行后被解密dump下来的镜像里是明文。修复思路是把运行时的明文节替换到静态文件里同时处理文件内可能存在的校验字段。这类修复没有固定模板需要边反汇编边看。我用pyelftools检查dump结果的频率很高几个关键命令能快速判断问题from elftools.elf.elffile import ELFFile with open(libjiagu_a64.so, rb) as f: elf ELFFile(f) for seg in elf.iter_segments(): print(seg[p_type], hex(seg[p_vaddr]), hex(seg[p_offset]), hex(seg[p_filesz]))如果段表输出正常下一步看符号表readelf -Ws libjiagu_a64.so | head -50如果能看到完整的UND符号导入函数说明基本能用了。如果UND为空需要回到上一步做重定位修复。3.4 验证还原是否成功还原不是“能拖进IDA不报错”就完事。我通常做三层验证确保还原结果是可靠的。第一层是静态验证。用IDA打开dump后的so搜索目标函数的特征。比如主逻辑里通常会有ptrace、open、read、strcmp等系统调用或者在代码里出现固定的错误日志字符串。如果反汇编结果里能直接看到这些调用和参数说明IAT修复基本没问题。第二层是动态对比。用Frida把目标函数的实际运行地址打印出来再与IDA里同一函数的地址做对比。计算偏移是否一致然后对比两头指令字节是否相同。如果指令完全一致说明动态dump和静态修复的结果与运行时状态吻合。第三层是闭环验证。修改so里一个不影响App功能的字节重新打包或者hook掉原始函数观察App是否出现预期变化。如果能证明还原结果可以用来做后续分析如果不能说明可能是text段里某些依赖没恢复完或者有其他保护机制在起作用。4. Activity生命周期重建实战4.1 加固后Activity经历了什么Native还原做完接下来是很多新手忽略的一个大坑App的入口Activity无法按正常流程拉起或者页面能出来但生命周期完全不正常。这背后涉及加固方案对Android组件注册机制的修改。加固后的Manifest里application和入口Activity都会被替换成壳自己的类。真实Activity的类名、路径、启动参数被加密存储运行时才由壳动态注册。脱壳后我们把真实Dex换了回去但Manifest没改系统启动时还是去找壳Activity自然走不到真实主页。更麻烦的是有些壳Activity在onCreate里动态加载真实代码把业务类实例化之后直接自己处理页面逻辑。这样真实Activity其实只是“借壳出生”并没有走AMS和ActivityThread的完整生命周期流程。结果就是脱壳后页面能显示但onResume、onPause、onDestroy等回调不按系统规则触发业务代码大量报错。4.2 定位生命周期分发点ActivityThread与Instrumentation要重建生命周期先要知道Android是怎么把生命周期事件送到Activity的。简化来看流程是这样的AMS通过Binder通知App进程的ActivityThreadActivityThread的handleLaunchActivity创建Activity实例接着调用Instrumentation.callActivityOnCreate、callActivityOnStart、callActivityOnResume等将生命周期回调依次发送给Activity。加固方案通常会在Instrumentation上做手脚。要么替换mInstrumentation为自定义子类拦截所有生命周期回调要么在ActivityThread的内部Handler里动态替换Intent中的Activity类名让系统以为要启动的是壳Activity。这样一来脱壳后的真实Activity就没有人替它触发生命周期了。所以要重建生命周期核心就想办法绕过壳的拦截让Instrumentation把事件发到真实Activity上。4.3 用动态代理Instrumentation重建生命周期我推荐的方案是动态代理Instrumentation。思路是用Java动态代理生成一个新的Instrumentation对象在方法调用时做一层拦截和替换把壳的Activity替换成真实Activity然后正常调用原始方法。具体步骤获取当前进程的ActivityThread实例。通常是静态字段sCurrentActivityThread反射即可拿到读取其中的mInstrumentation字段用Proxy为Instrumentation创建一个动态代理在callActivityOnCreate等方法里判断当前Activity是否为壳的代理如果是就替换成真实Activity将mInstrumentation字段替换为代理对象。核心伪代码如下Instrumentation original getOriginalInstrumentation(); Instrumentation proxy (Instrumentation) Proxy.newProxyInstance( getClass().getClassLoader(), new Class[]{Instrumentation.class}, (proxyObj, method, args) - { if (callActivityOnCreate.equals(method.getName())) { Activity origin (Activity) args[0]; Activity real replaceToRealActivity(origin); if (real ! null) args[0] real; } return method.invoke(original, args); }); setInstrumentation(proxy);这段逻辑里最关键的是replaceToRealActivity的实现。要从壳Activity里读出它内部保存的真实Activity类名再通过正确的ClassLoader去加载。不同加固方案的存储方式不一样有的放在Intent extra里有的放在静态变量里需要结合逆向结果具体分析。4.4 双ClassLoader导致的坑要提前处理重建生命周期时最容易栽跟头的不是Hook代码写错而是ClassLoader不对。加固方案通常会构造自己的ClassLoader去加载真实Dex系统ActivityThread里最初的那个ClassLoader是壳的两者不是同一个。如果代理方法返回的真实Activity是用壳ClassLoader加载的系统后续调用它的生命周期方法时会抛ClassCastException或ClassNotFoundException。出现这个报错十有八九是ClassLoader不匹配。我的处理方式是在Hook前先枚举所有ClassLoader记录下包含真实类名的那个。然后在替换Activity时用记录到的ClassLoader调用loadClass再通过newInstance创建实例。另外如果需要操作资源或主题还需要把真实Activity的Context关联好否则会崩溃在资源加载。这个阶段要反复测试因为不同版本Android的ActivityThread内部结构略有差异反射获取字段时最好都做try-catch兜底别因为一个字段反射失败就把整个Hook流程拖垮。4.5 验证生命周期是否恢复正常生命周期重建完成后验证标准就一句话让App像没加壳一样运行。我从三个维度检查冷启动流程从点击图标到进入主页面中间不应该有白屏、闪退生命周期回调顺序onCreate、onStart、onResume必须按顺序执行并且在切换到后台时onPause、onStop也能正常触发业务功能完整性登录、跳转、返回等关键路径不报错尤其要检查点击跳转时目标Activity是否能正常创建。如果验证到某一步失败回头从ClassLoader和Instrumentation代理两个点排查。绝大多数问题都出在这两个地方。5. 常见问题与排查技巧实录5.1 dump下来的so无法用IDA解析这个现象太常见了。拖进IDA提示“the input file is not a valid executable”或者直接花屏。多数情况下是dump脚本有问题。我的排查步骤是先看文件头用xxd对比dump出来的so前64字节和原始so是否一致。ELF头如果被截断后续所有操作都白搭再看段表里p_offset和p_vaddr关系。正常情况下第一个PT_LOAD段的p_offset和p_vaddr对应如果出现offset远大于vaddr或者反过来说明dump顺序有问题最后确认所有PT_LOAD段是否都包含在内。只拷r-xp段、漏掉rw-p段GOT表就会是空指针导入函数全丢。以我的经验dump脚本里最容易错的是按p_vaddr读取内存却按p_offset写入文件。这两个概念不能混否则文件结构彻底错乱。5.2 修正后函数列表不完整如果IDA打开后函数列表只有导出函数、导入函数一片空白说明符号表和重定位链有问题。常见原因是dump或修复过程中把rela.dyn、rela.plt这两段重定位数据弄丢了。解决办法是从原始so里把.rela.dyn、.rela.plt节拷贝到dump文件的对应偏移再按内存中的实际基址做一次批量重定位。pyelftools可以读取并解析relocation section配合循环写入就够用。需要说明的是内存里的重定位已经被动态链接器处理过了原始relocation entry和内存状态之间存在换算关系。直接拿原始rels去静态修复可能不匹配需要先算出链接器做了哪些改动再反向修正。这个过程比较繁琐建议先用简单so练熟再上大型so。5.3 生命周期Hook一点反应都没有如果你的Hook代码写对了但App表现完全没变化先别急着怀疑Hook本身。我排查时会按这个顺序来先确认Hook的ActivityThread对象是不是当前进程的。很多App是多进程承载Activity的进程和Application所在进程不是同一个再确认mInstrumentation字段是否已经被加固方案替换成了自己的实现。如果加固也替换了Instrumentation你需要先Hook住那个自定义实现再从它内部逻辑里找到真实Activity最后检查代理的ClassLoader和真实Activity的ClassLoader是否一致。不一致会导致替换出来的Activity无法被系统识别。这些坑我每个都踩过最快的一次排查花了半小时最慢的一次折腾了两天。经验就是用日志逐步打印别靠猜。5.4 关于反调试Frida进不去怎么办360企业版的反调试主要集中在so初始化和JNI_OnLoad阶段。常见的检测包括检查/proc/self/status里的TracerPid、检查端口、检测调试线程、检测ptrace状态等。我的处理顺序是先正常启动App等它初始化完成再延迟attach。很多检测只在启动早期生效晚点注入反而能绕过去如果延迟attach也被杀掉用Frida的早期注入模式在so加载前就先挂钩子点如果都不行就在模拟器上用反调试绕过工具处理基础检测。绕过反调试本质上只是为了拿到正常运行状态下的代码不要在它上面投入过多精力点到为止就行。下面是我常用的问题速查表现场定位时省时间问题常见原因排查/修复so拖入IDA报错段表错乱、文件截断检查p_offset与p_vaddr重新按PT_LOAD拼接函数列表只有导出符号表损坏补齐rela段做重定位GOT表是空值只dump了可执行段补dump rw-p段方法CodeItem为空没跑足业务路径覆盖更多功能、Hook ArtMethod::Invoke生命周期回调不触发ClassLoader不匹配收集所有ClassLoader再替换类启动即闪退壳检测被触发检查TracerPid、延迟attach结尾的个人体会这一整套流程走完我最大的感受是高级逆向实战绕来绕去还是绕不过对ART运行时和ELF加载机制的底层理解。360企业版在这两层的保护做得确实厚但它的每一次恢复动作都必须回到ART的调用约定上。只要盯住ArtMethod、CodeItem、Instrumentation这几个关键节点再厚的壳也有薄弱面。最后分享一个自己用着很顺的小套路分析前先给App做一次“全面热身”。启动后把所有功能菜单点一遍把所有能触发的方法触发一遍再等两三分钟让后台线程把该还原的代码都还原完最后才开始dump。这套热身流程让我的so转储成功率从六成提到了九成以上。下一次实战我会专门写VMP解释器指令集的还原思路那个坑比这次的还深。
返回列表