接手过OLLVM字符串加密样本的朋友应该都有过这种体验打开一个so文件所有关键字符串全是密文静态搜索完全没线索动态调试又不知道该从哪里下钩子。这阵子我又处理了一个用OLLVM混淆的Android样本调试过程中从.init_array一路追踪到JNI_OnLoad把字符串解密逻辑完整还原了出来整个过程比想象中顺畅也踩了不少众所周知的坑。这篇文章就把我的完整分析思路、关键操作、脚本片段和踩坑记录整理出来给正在跟同类样本较劲的同学一个参考。这篇文章适合已经会用Frida基础attach/intercept、但对OLLVM字符串加密还原路径还不够清晰的人。如果你是刚接触逆向不久的新手我也尽量把原理部分写透让你不仅会抄脚本还能理解每一步操作背后的逻辑。1. 先把解密时机搞清楚为什么是.init_array和JNI_OnLoad1.1 OLLVM字符串加密到底做了什么OLLVM对字符串的加密一句话解释就是编译器把源码里明文字符串常量替换成一段密文数据同时插入一个解密函数在程序运行时解密。和普通xor混淆不同的是OLLVM的加密往往会配合控制流平坦化把解密函数本身打散成一大堆基本块通过一个分发器dispatcher控制执行流跳转。直观感受一下原始解密函数可能只有几十行char *decrypt(char *cipher, int len, int key) { for (int i 0; i len; i) { cipher[i] ^ key; } return cipher; }经过OLLVM之后会变成数百个基本块每个基本块做完一点事就交还给分发器然后用一个全局状态变量来切换下一个要执行的基本块。这就是为什么你在IDA里看这类函数会感觉“完全看不懂”因为它根本没有平坦的、读起来顺畅的控制流。但有一点很关键无论控制流怎么打散函数入口参数、返回值和基本功能不会变。它终究是一个“接受密文、返回明文”的函数。所以我的思路就简单了——不跟它的内部逻辑死磕而是从调用关系和时间点切入动态抓到它解密前后的样子。1.2 so加载流程与解密逻辑的藏身处字符串解密逻辑在so文件里有几种常见的藏身位置。第一种是在.init_array段的构造函数里解密第二种是在JNI_OnLoad里调用解密函数还有一种是延迟解密即用到某个字符串时才当场解密这种情况下静态分析最痛苦因为你没法找到统一的“解密完成”时间点。这里得先理解so从加载到可用的完整流程System.loadLibrary()触发dlopen打开so文件。动态链接器执行so的.init段和.init_array段里的构造函数。so的构造函数执行完毕加载流程返回。Java层通过JNI调用so里的JNI_OnLoad执行注册native方法等初始化逻辑。JNI_OnLoad返回此后业务native方法才能被正常调用。题目里“从.init_array到JNI_OnLoad”这个范围其实就覆盖了so加载后最早的一段生命期。字符串解密如果发生在这段区间那你只需要在这两个位置下钩子就能把解密前后的状态都抓到。实测中很多加固样本会把字符串解密放在.init_array里这样Java层代码完全无感知JNI方法一进来拿到的就是明文。也有不少样本喜欢放在JNI_OnLoad里因为那里有JNIEnv指针可用解密后的字符串可以直接拿来注册native方法名。不管放在哪里只要你在JNI_OnLoad入口埋一个钩子至少能拿到“解密完成之后”的状态。1.3 为什么静态还原这条路很难走通我见过不少新人在处理OLLVM字符串加密时第一反应是找现成的OLLVM还原插件去静态还原。坦白说这条路效率很低。原因有几个第一OLLVM版本不同加密的存放格式、密钥生成方式都不一样很难有一种通用脚本覆盖所有情况。第二控制流平坦化严重破坏了函数结构即使你找到了解密函数想通过模拟执行还原它也很费劲。第三现代混淆还会叠加虚假控制流、指令替换、常量加密等手段静态插件的处理效果因人而异大部分时候你花了一两周研究插件怎么配动态方案早早就把结果拿出来了。所以我的建议是静态分析用来定位线索动态分析用来拿结果。两者结合效率才最高。2. 动手前的基本信息采集拿到模块、导出表与.init_array2.1 Frida环境与目标模块连接开始之前先把环境准备好。我用的是常规组合一台已root的Pixel类设备模拟器也可以但真机更接近目标样本遇到的真实环境另一个是安装了frida-server的对应版本电脑端是一个frida-tools。版本匹配是个老生常谈的坑但每次都有人中招——frida-server和电脑端frida版本不一致会导致attach时莫名其妙地断连所以我通常直接指定版本安装。Shell执行pip install frida16.x.x frida-tools adb push frida-server /data/local/tmp/ adb shell chmod x /data/local/tmp/frida-server adb shell /data/local/tmp/frida-server 连上之后先用一个最小脚本验证attach是否正常function main() { var targets Process.enumerateModules(); targets.forEach(function(m) { if (m.name.indexOf(demo) ! -1) { console.log(found module: m.name base m.base); } }); } setTimeout(main, 200);注意如果目标进程是刚启动的建议把hook逻辑放在Java.perform里等待时机或者直接使用frida -f 包名在启动时注入这样能避免进程还没加载so导致Module找不到的问题。实际调试时我很喜欢用-f拉起进程因为它能让我在so加载前就完成埋点。2.2 定位导出函数和JNI_OnLoad模块加载到内存后先看导出表。OLLVM混淆不会清掉JNI_OnLoad因为Java虚拟机要靠这个导出来调用初始化逻辑所以JNI_OnLoad几乎必然还在导出表里。var soName libdemo.so; var exports Module.enumerateExports(soName); exports.forEach(function(exp) { if (exp.name.indexOf(JNI) ! -1 || exp.name.indexOf(init) ! -1) { console.log(exp.name - exp.address); } });有些样本会隐藏部分导出但JNI_OnLoad一般仍在。如果确实枚举不到那就手动解析ELF导出表或者直接搜索二进制里的字符串JNI_OnLoad再回溯地址也能定位得到。其实真正需要关心的不只是JNI_OnLoad本身还包括它在加载流程里所处的位置。我习惯在JNI_OnLoad入口和出口都埋点这样能清楚知道两个关键信息JNI_OnLoad什么时候开始执行、什么时候结束。入口时机决定了我们能不能在.iInit_array里同时挂别的钩子。2.3 手动定位.init_array的两种办法如果样本的.init_array函数涉及字符串解密光看JNI_OnLoad是不够的因为你错过了解密进行中的每一个关键调用。这时候需要手动定位.init_array。第一种办法是通过Module.enumerateSymbols直接找var symbols Module.enumerateSymbols(soName); symbols.forEach(function(sym) { if (sym.name.indexOf(init) ! -1 || sym.name.indexOf(constructor) ! -1) { console.log(sym.name - sym.address); } });第二种更通用也更能让你理解原理的办法手工解析ELF的program headers找到.init_array对应的PT_LOAD段然后读里面的函数指针数组。ELF在内存中的结构并不复杂关键字段如下e_phoffprogram header表的偏移。e_phentsize每个program header的大小。e_phnumprogram header的数量。在这个加载上下文中.init_array实际上是一串函数指针动态链接器会依次调用它们。用Frida读这些指针并不复杂function readInitArray(base, phoff, phentsize, phnum) { var phAddr base.add(phoff); for (var i 0; i phnum; i) { var p_type phAddr.readU32(); var p_offset phAddr.add(4 * 4).readU64(); // 简化示例实际需要按32/64位结构调整 if (p_type 7) { // PT_DYNAMIC // 这里还需要解析DT_INIT_ARRAY / DT_INIT_ARRAYSZ } phAddr phAddr.add(phentsize); } }这段代码的意图不是让你直接抄而是让你理解.init_array不是一个符号而是一块运行时由链接器使用的数据区域。你完全可以用Frida从ELF头走到动态段再读取DT_INIT_ARRAY指针最终拿到构造函数列表。实操中我更倾向于更简单粗暴的做法直接在so加载时用Module.enumerateRanges(r--)找到base地址然后用十六进制搜索JNI_OnLoad函数名、搜索导入表特征反向定位到.init_array。但如果你只是做个通用记录脚本直接枚举构造函数就够了。3. 抓住解密函数从JNI_OnLoad入口反向定位3.1 解密函数常见形态与识别特征我在前面提过OLLVM解密函数会被控制流平坦化打散但函数的外部特征不会消失。在ARM64下有几种高频形态第一种函数签名类似char* (*)(char *cipher, int len, int key)。入参是密文地址返回值是明文地址或者直接原地修改密文内存返回同一个指针。第二种入参是一个结构体指针结构体里有密文长度、秘钥、密文数据等信息。第三种解密逻辑被内联到调用方意味着你找不到独立的解密函数。这种情况最麻烦只能从调用方内部去识别一段重复执行的解密循环。定位解密函数最实用的线索是交叉引用。你要先找一个你认识的调用者——比如JNI_OnLoad。如果JNI_OnLoad里调用了某个奇怪地址而且调用前传入的字符串看起来是乱码那这个被调用地址十有八九就是解密函数。实战里我喜欢这样先hook住JNI_OnLoad不看它内部逻辑而是把它的所有分支目标、所有被调用的目标都枚举出来然后逐个验证是否有“密文入、明文出”的行为。这个“行为验证法”在未知函数定位中极有效。3.2 在JNI_OnLoad入口埋点操作很简单var soName libdemo.so; var jniOnLoad Module.getExportByName(soName, JNI_OnLoad); Interceptor.attach(jniOnLoad, { onEnter: function(args) { console.log(JNI_OnLoad entered); // 在这里可以继续扫描so里的内存找可疑密文块 }, onLeave: function(retval) { console.log(JNI_OnLoad left, retval retval); } });这里为什么不直接在JNI_OnLoad基础上做字符串dump因为时机不对。JNI_OnLoad在.init_array之后执行如果解密在.init_array里已经完成那么你进入JNI_OnLoad时看到的字符串全是明文你确实可以直接dump但你拿不到解密过程的动态证据。如果你需要的是证据链——谁在什么时候解密了什么字符串那么得把钩子下得更早放到.init_array的构造函数里。做法是在JNI_OnLoad的onEnter里同步枚举.init_array并立即attach到这些构造函数。这看起来像“晚了”但frida的attach在大部分情况下仍能捕获构造函数执行完毕之后对解密函数的第一批调用。如果你的目标是抓解密函数本身那么只要在构造函数入口提前下好拦截点就能顺势抓到后面的所有调用。3.3 通过调用栈反查解密地址一旦你在JNI_OnLoad入口处看到了某个可疑调用下一步是抓调用栈。用Frida的Thread.backtrace非常简单Interceptor.attach(suspectFunc, { onEnter: function(args) { console.log(suspectFunc called from Thread.backtrace(this.context, Backtracer.ACCURATE) .map(DebugSymbol.fromAddress).join(\n)); console.log(arg0 args[0]); } });Thread.backtrace的返回值是一个地址数组再配合DebugSymbol.fromAddress可以解析出附近的符号名或模块偏移。OLLVM混淆后符号名通常被清除但模块基址偏移依然有很高价值——拿到偏移后放回IDA里看基本块位置就能知道你命中了哪个逻辑分支。这个方法定位解密函数特别顺手。我处理过的样本里有几次我在JNI_OnLoad里看到连续调用五六个不同地址它们看起来都像是同一个解密函数的不同实例OLLVM可能展开多次但只有其中一个函数真的接收了密文并返回了明文。通过比对调用栈和传入参数的内存内容很快就筛选出了真正的那个函数。4. 批量还原字符串主动调用解密函数的完整脚本4.1 先验证目标函数确实能解密定位到候选解密函数后不要急着批量扫描。第一步先验证它用IDA或者Frida读取函数入口处的几条指令确认函数没有带奇怪的前置检查比如检测调用来源。然后在Frida里构造一次主动调用把一个已知密文和长度推给函数看返回结果是不是可以识别的字符串。ARM64调用约定下参数依次在x0、x1、x2中返回值在x0里。我在Frida里用NativeFunction手动构造调用var decryptFunc Module.getBaseAddress(soName).add(0x1234); var decrypt new NativeFunction(decryptFunc, pointer, [pointer, int, int]); var cipherBuf Memory.alloc(64); cipherBuf.writeByteArray([0x3A, 0x4D, 0x52, 0x56, /* ... 密文数据 ... */]); var plainPtr decrypt(cipherBuf, 32, 7); console.log(decrypted: plainPtr.readUtf8String());这段验证能一次解决两个问题第一确认函数地址对不对第二确认解密参数密钥、长度的语义。如果返回的不是可读字符串检查一下传参顺序有时候OLLVM生成的解密函数第一个参数不是密文指针而是长度或一个结构体指针需要你在调用前适配。4.2 批量扫描密文并主动调用验证通过后就可以对so的可读数据区做批量扫描了。思路是枚举so的所有内存范围通常包括rx、rw段。在r--或r-x段里搜索“像密文”的数据块——比如高熵数据段里连续256字节且跟明文字符串不一样。对每个候选密文地址用解密函数主动调用再把结果按可打印字符串规则过滤。核心脚本可以长这样function scanAndDecrypt(soName, decryptFunc, key) { var ranges Process.enumerateRanges(r--); ranges.forEach(function(range) { if (range.file range.file.path.indexOf(soName) -1) return; // 用Memory.scan匹配特征比如连续4个字节超0x80的高熵块 Memory.scan(range.base, range.size, ?? ?? ?? ??, { onMatch: function(address, size) { var plain decryptFunc(address, 0x40, key); if (!plain.isNull()) { var s plain.readUtf8String(0x40); if (s s.length 4 /^[ -~]$/.test(s)) { console.log(offset address.sub(base) - s); } } }, onComplete: function() {} }); }); }这里Memory.scan的匹配模式我用的是通配“任意四字节”实际项目中你应该根据密文分布特征去写更精确的模式。否则全so扫描的开销很大加上每个候选都主动调用解密函数性能会明显变慢卡顿严重的时候还会影响目标app行为触发反调试。更好的办法是结合静态分析在IDA里先找到解密函数的交叉引用顺着交叉引用找密文表或者直接在.so十进制字符串区域找到那一段像密文的块。内存扫描只是兜底方案不能一上来就指望它。4.3 如何读取解密结果解密函数返回的结果不一定是在我们传入的密文缓冲区里也可能指向一个静态存储区或者以UTF-16/变长编码返回。读取时要灵活处理。大多数常规OLLVM字符串加密结果就是UTF-8明文所以readUtf8String(length)够用。但某些样本会保存Unicode字符串解密结果是UTF-16你用readUtf8String读到一半就发现乱码。推荐在脚本里同时测试UTF-8和UTF-16两种解析输出两种候选。var u8 plainPtr.readUtf8String(len); var u16 plainPtr.readUtf16String(len / 2); if (u8 /^[ -~]$/.test(u8)) console.log(utf8: u8); if (u16 /^[ -~]$/.test(u16)) console.log(utf16: u16);读取时还要注意越界问题解密函数返回的字符串不一定是你传入长度对应的完整字符串可能只是一个短字符串的地址。如果你传了0x40但实际密文只有8字节那读取0x40可能读越界把相邻内存数据也读出来。稳妥的做法是先根据返回地址所在的段范围动态判断长度上限再用这个上限截断读取。5. 动态追踪中的常见坑与排查思路5.1 时机太晚还没hook字符串已经解密完这是我遇到最多的情况。你觉得自己在JNI_OnLoad入口埋了“足够早”的钩子结果发现字符串早变成明文了完全看不到解密过程。原因很简单解密发生在.init_array里等你JNI_OnLoad被调用时人家已经解密完了。解决办法有两个方向。其一是把钩子下到.init_array构造函数上这一步要快——需要在so一加载就完成枚举和attach。用frida -f参数启动进程并在Process.enumerateModules的onModuleLoad回调里识别so然后立刻处理.init_array会比JNI_OnLoad时机提前不少。其二如果你不在乎过程只想要结果那JNI_OnLoad入口dump明文就够了绕过解密过程直接获得明文字符串。缺点是你不知道这些明文原来对应的密文在哪里反向定位业务逻辑稍麻烦。5.2 函数被内联或地址错误OLLVM在开启优化后会把小解密函数内联进调用者这时你搜不到一个独立的“解密函数”。从表现上看调用者内部有一段循环、栈上开了一个缓冲区、填入了常量key然后把一块高熵数据变换成明文。处理这类内联场景时我一般不从“找解密函数”入手而是直接hook调用者在调用者内部下断点等字符串解密完成后扫描调用者的返回地址或栈外的参数缓存区。或者更简单hook调用者在它调完解密逻辑后即某个分支点扫描它的栈和堆找出派生出的可读字符串。地址错误的另一常见原因是so被加载了多次不同ClassLoader各自加载一份。你用Module.getExportByName拿到的地址可能属于某一份so副本而实际业务代码用的是另一个副本。排查方法很简单Process.enumerateModules()里看一下有几个libdemo.so然后分别计算偏移确认你hook的那份副本是否是当前JiN方法在用的副本。5.3 反调试与多次加载问题Frida在部分加固样本上没法直接attach表现为启动即崩溃或attach超时。我的常规处理是先确认设备上frida-server版本是否匹配然后换成延迟attach方式先spawn再resume。如果样本已经做了针对frida端口和特征的检测可以考虑使用frida-gadget方式注入让so以library形式加载到进程里走正常流程。具体方案依赖样本的检测逻辑没有一招鲜但经验是检测通常会滞后几十到几百毫秒先hook早期函数往往能绕开。多次加载问题前面提过这里再补充一个必要动作代码里要记录每次onModuleLoad的时间戳并观察JNI_OnLoad实际是从哪个副本被调用的。你可能会发现app第一次加载的副本没有执行任何解密真正干活的其实是第二次加载的副本这种情况不追踪调用方根本发现不了。6. 延伸一点把解密结果和代码逻辑串起来6.1 对解密后的字符串做交叉引用拿到一组明文字符串后不要满足于“哦解密成功了”。逆向分析的最终目标是搞懂业务逻辑。我把解密结果导出成文件后会逐一搜索这些明文在内存里的引用点。Frida里可以这样快速定位明文字符串的使用者var plain some_interesting_string; var plainBuf Memory.allocUtf8String(plain); var refs []; Process.enumerateRanges(r-x).forEach(function(range) { Memory.scan(range.base, range.size, hexdump(plainBuf), { onMatch: function(addr) { refs.push(addr); } }); });这里把明文字符串当成一个交叉引用目标在可执行段里搜索哪些指令引用了这个地址。得到结果后再对照模块偏移放回IDA里看函数逻辑就能从“解密字符串”推进到“理解某个核心算法”。这个步骤常常让人豁然开朗某个加密算法里使用的key、某个协议里的魔数、某个接口的路径全部在字符串解密后被解开整个业务逻辑像拼图一样补全了。6.2 把分析结果沉淀为可复用工具一次实战后我会把找到的so名、解密函数偏移、参数语义、密文分布结构记录成一个小的frida脚本模板。下次遇到同类OLLVM样本时就不再需要从头摸索而是直接套模板改一下偏移就能跑。沉淀时要注意记录这些信息解密函数在模块中的偏移函数签名密文地址、长度、密钥分别在第几个参数解密结果编码UTF-8或UTF-16字符串密文在so文件里的存放区域和特征解密发生的时间点.init_array还是JNI_OnLoad还是延迟解密这些信息整理成一份Markdown笔记比单纯保存脚本有价值得多。因为过几个月你自己回来时记忆已经不新鲜了但结构化的记录能让你快速恢复上下文。在我实际处理过的一个样本里解密发生在JNI_OnLoad参数是一个结构体指针结构体内部前8字节是密钥随后是密文块和长度。由于当时把参数结构记录得很清楚后续我甚至直接写了个独立工具输入一个密文文件就能还原全部明文再也不用每次打开Frida。这种“一次调试长期复用”的做法是处理重复性逆向工作最有效的策略。最后再分享一个小技巧动态追踪OLLVM字符串解密时不要一口气把所有字符串都dump出来。先挑两三个看起来最关键的明文比如URL、密钥、函数名确认它们在业务里对应的位置再决定是否全量扫描。这样既能快速验证解密函数定位准确也能避免在海量输出里迷失方向。
阅读完成 · 觉得有帮助?