ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Frida三层次Hook实战:VMP与OLLVM双重加固逆向

Frida三层次Hook实战:VMP与OLLVM双重加固逆向 VMP和OLLVM这两个词搞安卓逆向的兄弟看到基本都要皱一下眉头。做过几个大厂加固样本之后我的感受很直接VMP把代码抽成了自定义字节码跑在一个私有解释器里你静态分析IDA里看到的只是一层壳OLLVM把控制流拍扁成状态机真实逻辑散落在几百个基本块里看伪代码能看得人脑仁疼。但如果把Frida用对路子在正确的层次上挂钩子逆向效率能快很多。这篇文章就拿一个典型的VMP加OLLVM双重加固APK为例把我实际试过有效的一套Frida三层次钩子思路完整拆出来。所谓三层次指的是Java层入口钩子、So层JNI函数追踪、以及绕过OLLVM控制流平坦化之后在解释器层或关键函数层做寄存器级别追踪三个层面配合起来才能把加固壳扒干净。内容涉及原理、脚本、踩坑实录感兴趣的可以直接照着抄作业。1. 先搞清楚VMP和OLLVM各自到底加固了什么很多人一上来就Frida挂脚本结果发现该崩还是崩、该找不到符号还是找不到本质上是因为没搞明白这两个加固方案各自的原理定位。把底牌看清楚了后面的事才能顺。1.1 VMP的核心机制代码变成数据逻辑藏进解释器VMPVMProtect的核心思路是“指令虚拟化”。它把原始的CPU指令ARM汇编先翻译成一套自定义的字节码然后在运行时用一个解释器VM dispatcher去解释执行这些字节码。简单说原来add、sub、mov这些指令变成了一堆看起来毫无意义的opcode真正干活的逻辑被塞进了私有解释器里。这对静态分析的打击是毁灭性的你在IDA里看到的函数根本不是原函数而是一个散落着几个字节码数据块的壳。你想还原原本的if/else逻辑、循环、加密算法常量除非你把整个解释器逆向清楚否则无从谈起。VMP还有一个可怕的地方是它会做“反调试”——检测到调试器、Frida特征、ptrace附加等情况就退出或跑飞这是后话。拿VMP加壳的APK来说它往往把关键算法比如加密解密函数、签名校验函数抽到了so层的某个自定义虚拟机里Java层只剩下一个调用入口。所以逆向的第一步是先找到这个入口再看清解释器的整体形态。1.2 OLLVM的混淆策略控制流被打成迷宫数据被加密OLLVM是另一套思路它不抽取代码而是做混淆。常见的是三个核心pass控制流平坦化flattening整个函数原本的顺序执行流程被转成一个主分发器dispatcher 无数个基本块bb每个基本块执行完就跳回分发器分发器根据状态变量决定下一个基本块是谁。伪代码看上去就是一大片switch-case套while。指令替换substitution对运算指令做等价替换把简单的add变成一堆位运算、取反、移位操作读起来极其费劲。字符串加密常量字符串会被隐藏起来运行时才解密所以你strings一下什么都看不到。VMP偏重“把代码藏起来”OLLVM偏重“把代码搅碎”两者叠加之后静态层面几乎无解。正因如此动态层用Frida做三层次hook才有不可替代的价值——动态执行时代码必须现出原形我们只需要在正确的位置“抓现行”。2. 三层次钩子体系从Java层一路捅到解释器最深处在我试过的多种方案里对VMPOLLVM最稳的是下面这套三层次hook方案。它依赖Frida但绝不是随便hook几个函数就能出活每一层都有明确的职责和打法。2.1 第一层Java层入口钩子锁定调用边界尽管核心逻辑被加固但App的对外接口、Activity加载、JNI调用入口往往还在Java层或者至少能从Java层hook到。第一层的关键任务有两个一是确认目标逻辑被调用的时机二是拿到Java层的参数与返回值给自己建立“参照系”。实际操作中我通常会先反编译APK定位到目标功能所在的类名和方法名然后用Java.perform去hookJava.perform(function () { var targetClass Java.use(com.example.app.core.Cryptor); targetClass.encrypt.implementation function (data) { console.log([Java] encrypt called, data data); var result this.encrypt(data); console.log([Java] encrypt result result); return result; }; });这一层本身不负责破解VMP它负责让你知道“什么时候、以什么参数、走到了哪一步”。没有这个参照系你在so层瞎锤半天可能搞得是别家逻辑。另外很多加固APK在Application和Activity的加载流程里做了“自校验”或“反调试调度”Java层的hook能帮你快速发现这些安全模块的触发点。举个例子我之前遇到过一个样本它的反调试线程在AttachmentManager.ensureRunning()里被拉起来Java层hook到这一行再返回false就能安全跳过一大部分反调试逻辑。2.2 第二层So层JNI函数与导出符号追踪找到动态注册入口绝大多数VMP壳的入口在native层Java层最终会通过JNI调用so里的函数。第二层的核心是找JNI函数、找注册表、找加密逻辑所在的so库函数边界。首先用Frida枚举目标so的导出函数var exports Process.findModuleByName(libsec.so).enumerateExports(); exports.forEach(function (exp) { if (exp.name.includes(Java_) || exp.name.includes(encrypt)) { console.log(exp.name exp.address); } });对于动态注册的RegisterNatives需要主动去看JNI_OnLoad里做了什么。一个比较高效的办法是直接用Frida的Interceptor.attach把RegisterNatives给钩住把所有动态注册的native方法名与函数地址对应关系打出来。第二层的产出是一张“Java native方法名 - so导出/动态函数地址”的映射表。拿到这个表之后你再根据Java层hook到的调用栈迅速定位到哪个函数是实现目标逻辑的主入口。2.3 第三层解释器/关键函数内部的数据流追踪这是整个方案里最有技术含量的一步。VMP的so里虽然有导出函数但导出函数内部显然不会是你想要的明文逻辑——它通常只是“启动解释器”的外壳。第三层要做的是深入解释器内部观察解释器分发的时机、执行的逻辑块以及OLLVM平坦化后状态机的关键变量。多少有一点赌的成分但绝大多数商用方案的so结构都逃不开这几个模式VM入口函数读取字节码段初始化寄存器栈/内存栈进入dispatch loop。主分发器函数每轮循环都会根据下一个opcode跳转执行。执行块函数把某几个opcode组合成一个小逻辑片段对应一个具体的操作。我的习惯是先用特征码在so里搜“dispatch loop”的位置再用Frida在内存中对dispatch地址做断点。比如搜索一段典型的VM入口特征一堆mov、push、ldr然后是一个循环结构的循环体地址或者直接通过IDA静态找到疑似解释器loop的地址然后Frida下断var dispatcher Module.findBaseAddress(libsec.so).add(0x12A34); Interceptor.attach(dispatcher, { onEnter: function (args) { this.opcode Memory.readU8(args[0]); console.log([VM] dispatch, opcode this.opcode , ip args[0]); }, onLeave: function (retval) { console.log([VM] dispatch finished, regs: ...); } });在解释器层面完成Hook之后你会发现目标函数的执行过程就像一串流水账每一个opcode对应一次运算。把这些opcode和参与的寄存器、内存值记录下来再对照我们Java层拿到的明文输入输出就能够低成本还原“算法逻辑长什么样”——至少能还原出核心的加密/解密流程而不是被一堆平坦化基本块带偏。3. 实操记录从启动App到Dump DEX再到破开OLLVM状态机光说不练假把式。下面这一节我按时间线把一次完整实操记录下来。样本是按标题特性选的一款带VMPOLLVM加固的实验APK取名为demo.apk核心诉求是定位它内部一个加密算法的实现逻辑。3.1 环境准备与Frida启动方式的选择我用的环境是Pixel模拟器Android 10 Frida 16.x 一台Linux主机。启动方式要强调一下对于加固App用spawn模式而不是attach模式因为很多加固App在启动早期就有反调试、反Frida检测你在它完全跑起来之后再attach很可能被检测到或者错过早期逻辑。frida -U -f com.example.demo -l hook.js --no-pause--no-pause的意思是让App继续主线程跑否则某些等待主线程的加固逻辑会卡住。另外建议先在启动早期就把ptrace调用给hook掉很多壳都会用ptrace做调试状态检测var ptrace Module.findExportByName(null, ptrace); Interceptor.replace(ptrace, new NativeCallback(function (request, pid, addr, data) { console.log([anti-debug] ptrace called, request request); return 0; // 模拟ptrace成功但不实际附加 }, int, [int, int, pointer, pointer]));实测这一段能挡掉相当一部分站在“检测到调试器就退出”路线上的反调试逻辑。不过也要注意个别壳会校验ptrace的返回值是否真的反映“自己已被跟踪”的状态如果遇到这种需要改成定制化返回不能一律返回0。3.2 用Frida快速定位目标入口并Dump DEX启动之后我的脚本第一件事是hook几个关键类让App走到加密逻辑。对demo这类的实验样本主界面有个“加密”按钮点击后调用com.example.demo.core.Cryptor.encryptString。通过第一层Java hook很快拿到调用栈和测试数据。接下来不能光看Java还得拿到脱壳后的DEX。很多VMP加固的DEX在运行时会被还原到内存那么在内存里直接Dump再修复是最快的路径。最简单的方案是直接上frida-dexdumppython3 frida_dexdump.py -U -f com.example.demo它会遍历内存里的dex魔数dex\n035\0把整个dex镜像抓下来。大多数情况下dump完的dex直接拖进jadx就能看但有的时候会碰到“DEX被抽取dex2c/dex方法体抽取”的情况dump下来的壳里方法体是空的那要配合后面的Unidbg模拟执行或主动调用还原。值得注意的一点是frida-dexdump只能抓到“已经加载进内存”并且“还是明文dex”的模块。如果VMP已经把代码抽走只剩一个空方法占位你dump到的东西会是空壳dex但这不影响我们继续用native层追踪去还原真正算法逻辑。3.3 对OLLVM平坦化函数做定位抖动法把DEX拖进jadx之后我已经知道Java层调了libsec.so里的Java_com_example_demo_core_Cryptor_encryptString。但我们打开IDA看这个导出函数看到的正是被OLLVM平坦化过的巨型函数几百个基本块全部连到一个主分发器整段伪代码就是while(true) switch(state)没有一条直线流程。这种时候不要妄图静态恢复全部逻辑我推荐一个行之有效的动态定位方法我叫它“抖动法”用Frida对目标native函数下断点Hook函数起始地址。每次执行到目标函数就记录当前寄存器的值、栈区内容和返回地址。输入不同的明文数据多次调用目标函数。观察“哪些基本块被反复执行”以及“哪些局部变量值随输入变化”。与被输入值关联的块基本就是算法的主干路径与输入值无关的块大概率是平坦化带来的废块或常量运算块。这一步在IDA里配合Frida的trace同步看效果非常好。var targetFunc Module.findBaseAddress(libsec.so).add(0x7E890); var inputArg null; Interceptor.attach(targetFunc, { onEnter: function (args) { inputArg Memory.readCString(args[1]); // 假设arg1是输入字符串 console.log([Native] encryptString( inputArg )); // 开启指令级trace this.traceObj Stalker.follow(); }, onLeave: function (retval) { var result Memory.readCString(retval); console.log([Native] result result); Stalker.unfollow(); } });Stalker.follow()是Frida的指令级trace能力能拿到每个基本块的执行历史。虽然全量trace性能开销不小但用在单次调用场景没问题。3.4 还原OLLVM主分发器与状态变量的取值关系有了trace结果下一步是搞清OLLVM主分发器到底在哪个地址、状态变量存在哪。通常情况下OLLVM平坦化函数里有一个核心寄存器或栈变量专门保存当前状态分发器每次通过它决定跳去下一个基本块。我的做法是对trace中每一块基本块的入口做Feature压缩统计哪些块是所谓“分发块”即“下一跳不固定依赖某个变量”的块。再通过Frida把那个变量的地址或者寄存器号打印出来手动确定状态机的取值与真实块的映射关系。不过在真实加固App里OLLVM可能已经不是早期vanilla版的单层平坦化而是多层嵌套加虚假控制流。这种情况下你会发现“分发器A决定块B块B里面又嵌套一个分发器C”一层套一层。打的时候要有耐心逐层拆还是按同样的方式对每个状态变量做取值记录再映射对应执行块。这里有一个非常实用的经验在OLLVM函数里找真实块看不看这块是否会被多次执行且内部有实际内存读写。平坦化加的垃圾块通常要么直接转发、要么做无意义比较执行频率高但逻辑密度低真正干活的块一定有对传入数据的内存读取和运算。把trace结果里访问内存的指令单独筛选出来再结合栈帧回溯大概率能锁定真正的加密核心。4. VMP解释器的进一步追踪与修复OLLVM的平坦化破解到这一步基本能把加密流程的轮廓画出来了。但如果样本用了比较重的VMP把核心算法本身抽到了私有解释器里那刚才那些“OLLVM基本块”里可能仍然没有真正的运算细节——只有一遍遍调用解释器分发的动作。这种情况我们必须把目光集中到解释器本身。4.1 在内存中定位解释器字节码与Dispatch循环一个VMP函数通常由“字节码区”和“解释器代码区”构成。字节码区全是私有opcode可以放在so的.data段也可以运行时动态解密到堆里。解释器代码区则是一个循环结构每轮循环读取下一条opcode通过一个大的跳转表把执行流转到不同handler。想定位解释器dispatch循环可以用Frida在内存中搜索循环体模式。比较傻但好用的办法是hook目标函数后记录PC值出现的频次PC落在某段地址区间次数最多的那一段往往就是dispatch loop中心区。var pcCounts {}; Interceptor.attach(targetFunc, { onEnter: function () { this.addrArr []; }, onLeave: function () { this.addrArr.forEach(function (addr) { var pc addr.toString(); pcCounts[pc] (pcCounts[pc] || 0) 1; }); // 输出出现次数最高的前20个地址 var sorted Object.entries(pcCounts).sort((a, b) b[1] - a[1]).slice(0, 20); sorted.forEach(function (item) { console.log(PC item[0] hits item[1]); }); } });配合Stalker的轨迹你能拿到完整的执行指令流。把执行频率最高的那个地址附近的代码在IDA里反汇编一下基本就能看到dispatch loop的真容。如果生效loop被伪装成随机跳转表那么就要从“每次跳转的目标地址”反推跳转表这时候用Frida把每一次跳转的源地址与目标地址打出来会异常清晰。4.2 处理VMP字节码的动态解密与指令抽取很多VMP壳不会把opcode直接摆在so里而是启动时用RC4、XXTEA之类的算法解密。这就会出现一种情况你再怎么hook解释器看到的始终是一堆乱码因为字节码是“临时解密出来的”用完就销毁。应对思路有两类一类是hook解密函数本身。如果能看到解密后落地的内存块直接在落地内存上做快照再去分析opcode含义。定位解密函数的方式很简单——记录目标函数被调用时的so内存访问变化一旦发现有内存区域在首次调用时从“不可读”变成“可读”大概率就是解密后的字节码缓存。另一类是hook解释器的取指函数。解释器必须从某个地址读opcode无论它是否解密最终都要经过一个load指令。把Frida hook在那条load指令对应的地址处然后通过寄存器值拿到当前opcode值再配合handler跳转记录下来就能在不关心解密算法的情况下拿到完整的执行log。可以说一旦走到解释器的取指层VMP的“黑盒”就变成了“透明的指令流”。到这一步剩下的工作量就是“翻译抽象层”把opcode序列还原成伪代码。建议先自己总结一小部分opcode的行为模型例如0x01代表加法、0x02代表入栈、0x0A代表内存加载再用脚本把日志自动化翻译效率会高很多。4.3 自定义字节码的Opcode语义推断实操讲到自定义opcode翻译不少新人对“怎么猜opcode含义”感到无从下手。我分享一个自己总结的操作套路输入一组已知数据比如0和1作为加密输入记录全部opcode日志。输入数字2、3、4重复一次。对比两次日志看哪些地方的处理会随输入不同而不同。“取参数”“执行运算”“写回结果”这三类逻辑必然出现在执行路径里。如果某一段opcode序列对输入值0和1的处理差异巨大那这一段大概率包含分支。把这个过程和上面“抖动法”合并可以不停地用Frida灌入大量样例数据自动收集足够多的opcode执行轨迹再用Python脚本离线分析出opcode与语义的映射关系。实测下来这种“半自动化黑盒推断”方法能在2到3小时内搞定一个不算特别变态的VMP解释器。当然如果VMP还掺杂了加花指令、多态变形那opcode序列就会在不同调用间发生变化不能直接纯自动化。遇到这种情况建议先用“等价执行块合并”的思路把“总是连续执行并且对寄存器影响等价”的opcode序列绑定成一个语义单元再去分析。这一步比较费时但确实有效。5. 常见问题与排查技巧实录做过不少加固样本也踩过太多坑。下面这些问题基本是每次实操都会遇到的整理成速查希望对你有帮助。5.1 反调试与反Frida检测导致Shell中断最常遇到的是Frida一挂上去App直接闪退或者卡在启动画面。处理思路有三个优先禁用ptrace检测。很多壳会在JNI_OnLoad里fork一个守护线程不断检测ptrace(PTRACE_TRACEME)一旦发现返回值为0就自杀。Frida启动时自身也会ptrace所以把ptrace返回值处理成“已经被跟踪但装成没事”往往是第一步。一个常见做法是直接return 0但有个别壳会校验/proc/self/status里的TracerPid这种情况下建议顺带把TracerPid字段抹掉。用gum-js-loop或者Stalker做短期trace避免长时间hook导致壳的反篡改检测触发。很多壳会定时对关键函数做CPU指令哈希校验发现被修改就直接崩溃。尽量避免用老版本的Frida。新版本Frida在隐蔽性上有不少改进实验下来Frida 16.x在大部分样本上的稳定性比15.x强很多。反调试的处理不是一劳永逸的它更像是“你补一个洞壳又挖一个洞”的对抗。关键思路是先在Java层/so层把所有检测点找到然后逐一中和千万别在没处理反调试的情况下直接挂trace脚本。5.2 DEX Dump不完整或方法体为空用frida-dexdump抓出来的包在jadx里打开发现方法体是空的或者只有return null这是抽取加固DEX抽取最常见的特征。处理方向有两个使用r0capture或类似的frida脚本去hookart::ClassLinker::LoadMethod在方法被真正加载执行时把CodeItem抓下来回填到DEX里。对于一些特殊抽取壳干脆放弃还原DEX方法体直接hook对应native函数用native层的结果反推Java层行为。实际工作上我倾向于优先用native层方案因为DEX修复耗费的精力往往够你把native层整个功能钩出来了。但如果你是想把人家的全部代码静态看一遍做学习研究r0capture就挺好用。5.3 Stalker全量Trace性能爆炸或导致崩溃对VMPOLLVM样本做全量Stalker trace很容易造成几十倍甚至上百倍的性能下降App会出现超时卡死或ANR。我的建议是千万不要一上来就对整个函数全量trace要先做“局部trace 条件过滤”。例如只trace特定输入参数条件下的调用或者只trace特定地址区间的执行流。更稳妥的方式是利用Frida的Stalker.follow配合trusted_threshold调优或者干脆用Interceptor.attach对特定基本块做插桩。挨个断点虽然繁琐但在稳定性上远胜全量trace。5.4 工具链选型Frida之外还需要什么Frida是核心但不是唯一。我自己的工具链是jadx GDA快速看Java层结构。IDA Pro / Ghidra看so层反汇编配合Frida做地址换算。r2frida在二进制分析和Frida之间切换比较顺滑。jnitrace专门追踪JNI调用对定位动态注册、JNI函数参数和返回值很有帮助。unidbg当你想在本地模拟执行so的部分逻辑时极其强大尤其在Frida被反调试攻击得太严重时unidbg能绕开很多检测直接跑算法逻辑。5.5 Frida脚本的内存地址换算问题最后提醒一个在地址换算上特别容易犯的错Frida里Module.findBaseAddress(libsec.so).add(0x7E890)得到的是运行时地址而IDA里看到的地址是静态基址。如果so在运行时被系统加载到了随机地址ASLRFrida的base address自然能帮你换算好但如果so本身在运行时被脱壳或重定位了段偏移那IDA静态地址和实际运行地址之间可能不再是一个固定差值。碰到这种情况建议先用Process.enumerateModules()确认模块真实基址再结合内存搜索特征指令来定位。还有一个小技巧当你在IDA里看到一个关键函数的偏移是0x7E890但不知道这个so加载后是否被做了运行时patch可以先读一下这个地址前16字节看是否和IDA反汇编出的内容一致。不一致的话说明壳在运行时改写代码这时候再去硬跟就没意义了需要先抓取patch后的代码快照。写在最后的一点实战体会我做了这么多年逆向最大的感受是面对VMP和OLLVM这类重加固千万别想着一步到位、静态通吃那只会把自己逼疯。动态方案的命门在于“在正确的时间、正确的层次下钩子”Java层拿入口so层拿边界解释器层拿逻辑三层次缺一不可。实际操作中只要把层次的边界划分清楚了哪怕某个环节卡住也能快速回退到上一层次继续推进不至于全盘卡死。另外遇到Frida被反调试压得生不如死时别硬扛切到unidbg或者换种启动方式往往柳暗花明。这套三层次钩子方法论我后面还在持续迭代比如加入对art hook、对native内存分配追踪等更精细的控制。如果你也在啃这类样本不妨从这个框架入手先跑通一条路再慢慢打磨自己顺手的那几层。
返回列表