☰
OLLVM混淆So库登录参数逆向分析:从定位到算法还原
2026/9/28 12:06:19 网站建设 项目流程

接到一个登录接口鉴权逻辑的分析任务,拿到手里的so文件一打开,满屏的switch-case分发块扑面而来,我就知道——又是OLLVM。这几年商业软件和App为了保护登录参数,几乎把OLLVM系列混淆当成了标配。你要是做安全分析、接口逆向、风控对抗,迟早得和这玩意儿正面碰一回。这篇文章就聊我这次分析登录参数的全过程,从定位函数、识别混淆特征,到还原加密算法、动态验证,把能直接复用的思路和踩过的坑都摊开说清楚。适合正在啃混淆so的新手,也适合遇到过F5出“天书”但还没找到体系化思路的同行。所有内容仅供合法授权范围内的安全研究参考,别拿去做不该做的事。

1. 先说清楚这个分析任务的关键点

1.1 项目背景到底是个什么活儿

这次要分析的软件是一个带登录功能的客户端,登录请求里除了明文用户名,还跟着一串加密的password和sign参数。抓包能看到参数,但不知道生成逻辑,就没法在自动化测试、数据采集或安全评估中合法地复现请求。问题在于,客户端侧的加密逻辑不是普通的Java层代码,而是下沉到了so库里,并且这个so经过了OLLVM变形。

所谓“下沉到so库”,就是说你在Java层只能找到一个类似encrypt()的native方法申明,真正干活的代码在libxxx.so里。Java层的反编译很清爽,顶多看到个System.loadLibrary()和几个native方法。但你一旦用IDA打开so,看到的就不是正常人能读的C代码了,而是一堆判断分支被打散成“调度器+状态码”的扁平结构,函数的本来面目被完全肢解。

这就是OLLVM混淆最烦人的地方。它不加密你的代码,也不做虚拟机保护,而是把代码结构改得面目全非。对于“登录参数分析”这个目标来说,最直接的影响就是:你很难从反编译代码里一眼看出“它调用了哪个hash算法”“操作数是怎么拼的”“key是怎么来的”。

所以整个分析任务的本质,不是“破解”什么,而是“翻译”被混淆后的算法逻辑,把它还原成可理解、可验证的等价实现。

1.2 OLLVM混淆会给你怎样的“见面礼”

OLLVM严格说不是一个软件,而是一套基于LLVM架构的代码混淆工具集。它有几个典型的变形手法,你在分析时会反复遇到:

  • 指令替换:把简单运算改成数学上等价但形式上复杂的表达式。比如a + b变成a - (~b) - 1,或者把位运算变成(a ^ b) + 2 * (a & b)这种形式。变量还是那个变量,逻辑还是那个逻辑,但代码读起来恶心了不止一个档次。
  • 控制流平坦化:这是“见面礼”里最扎眼的。原始函数如果是一个if-else加几段顺序代码,混淆后会变成一个大while循环套switch-case的结构。通过一个“状态变量”在不同case之间跳转,真实逻辑块被拆成零散碎片,顺序关系全部由状态码决定。
  • 虚假控制流:插入大量带有“不透明谓词”的假分支。比如永远为真的if (1 == 1),但外面套上复杂运算伪装,引向一个永远走不到的死块。瀑布流式的复杂跳转一下就出来了,扰乱你的静态分析节奏。

这三个手段经常叠加使用,导致你在IDA里既分不清真实路径,也理不顺运算顺序。很多初学者第一反应是用脚本把混淆块全部还原,结果一晚上就耗在“去平坦化”的泥潭里了。

我的建议是:别一开始就死磕全量去混淆,先确定“输入从哪进、输出从哪出”,用动态调试把函数边界切出来,再去局部还原算法,效率高得多。这也是这次分析的总体思路——先抓包定范围,再动态定边界,最后静态攻核心算法。

2. 工具准备与环境搭建

2.1 这活儿要用的工具有哪些

分析一个混淆so,我通常准备四类工具:静态反编译器、动态调试框架、模拟执行平台、抓包工具。缺一不可,各有各的用处。

静态反编译我用IDA Pro 8.x,配合Hex-Rays插件。Ghidra也能用,但对OLLVM美化后的控制流,IDA的F5+手动标注体验还是好一点。你需要重点关注的是:能看懂伪代码、能手动改函数签名、能在指令级别打标签。IDA 7.5以上的版本对ARM64支持很好,分析Android Native库基本够用。

动态调试首选Frida。它做两件事:第一,Hook Java层,快速确认native方法入口参数;第二,Hook Native层,在函数边界上抓输入输出。对于OLLVM混淆的代码,动态信息往往比静态分析可靠,因为你是直接观察真实执行路径,而不是猜被扁平化之后的分支关系。

模拟执行我用unidbg。这是一个能把Android so文件直接在PC上跑起来的Java框架,最妙的是你不用启动模拟器,也不用连真机。它能模拟JNI调用、系统调用、内存分配,让你在“无真机”环境下反复调用同一个函数做对照实验。对于登录参数这种以“输入字符串—输出结果”为特征的目标,unidbg几乎是效率最高的验证平台。

抓包工具就不多说了,Charles或mitmproxy都行。注意现在很多App做了证书校验,你需要在Android端配置代理并安装CA证书,如果遇到SSLPinning,还得用Frida去Bad来绕过。

2.2 环境配置的几个细节

工欲善其事必先利其器,但配置环境时有几个坑值得提前说。

如果是分析Android的so,你先搞清楚架构。现在绝大多数是arm64-v8a,但老设备上可能还有armeabi-v7a的包。两者指令集不同,混用IDA的ARM插件会导致分析结果极不靠谱。建议用readelf -h libxxx.so看ELF头里的Machine字段,是AArch64还是ARM,先确认再开工。

Frida部分,注意frida-server版本必须和电脑上的frida-python、frida-tools版本保持一致。Android系统版本也会影响兼容性,我建议优先用Pixel系Google原生系统的设备做动态调试,其他厂商系统经常有额外的GKI或内核锁问题,折腾起来很费时间。

unidbg对JDK版本有要求,推荐JDK 11配官方示例工程,别图省事用JDK 8,jni解析上容易出幺蛾子。另外unidbg并不是所有so都能开箱即跑,遇到JNI_OnLoad里主动注册了大量native方法的情况,你需要提前把System.loadLibrary、dlopen这些路径打通,必要时还要补JavaVM的虚函数表。

提示:分析前先把so文件丢进file命令看一眼,是strip过还是带符号。如果是带符号版本,那真是捡到宝,OLLVM再混淆也一堆符号名给你指路。

3. 定位登录逻辑的实操思路

3.1 从网络层入手找突破口

很多人一上来就扎进IDA里找函数,那是本末倒置。正确顺序应该是:先抓包,搞清楚请求体里有哪几个字段、哪些是加密的、哪些是关联的。

以这次登录请求为例,抓包看到的POST体大概是:

username=admin&password=9f9f2c1d...&sign=7a3b1e20...&ts=1712345678&nonce=abc123

password明显是密文,sign是一串定长的hex,ts是Unix时间戳,nonce像是一个随机串。这里就能猜出几个关键点:ts和nonce大概率是参与加密或签名计算的原材料,sign可能是MD5/SHA系列的摘要,也可能是HMAC。

这种初步判断很有用,它给了你后续hook的“靶子”。你已经知道要追踪ts+nonce+password的内容流,而不需要去猜算法是AES还是RSA。同时,你可以直接搜索so库的只读字符串区,看有没有Base64表、SHA256的初始常量,甚至Google搜索网络上的公开分析报告,快速缩小算法范围。

3.2 用字符串与导出表锁定native函数

定位native函数入口的常规套路有三步:

第一,看Java层的native方法申明。比如某个EncryptUtils类里有public static native String encrypt(String data, String key),通过Frida的Java.perform能直接拿到Java_com_example_EncryptUtils_encrypt这个导出符号,前提是so没做符号去除。现在很多OLLVM加固方案会strip符号表,但你仍然可以搜索JNI动态注册的痕迹。在JNI_OnLoad函数里,会有一串JNINativeMethod结构体,里面记录着Java方法名和native函数指针。找到它,等于找到了所有快捷键的说明书。

第二,如果JNI_OnLoad也被混淆了,直接搜索字符串,比如"encrypt"、"sign"、"password"这些方法名,通过交叉引用定位。OLLVM混淆不会把字符串常量也变性,它们通常以明文躺在.rodata里。用IDA的Strings窗口搜一遍,再Alt+T跳转到引用处,往往就能找到注册表代码。

第三,看导入表。一个加密流程大概率会用到memcpy、strlen、malloc、memcmp等C库函数。比如memcmp在签名校验的最后一步几乎是必然出现的。你把这些导入函数加上交叉引用,顺着调用点往上追,就能圈出一个或者几个候选函数。

3.3 用Frida的Hook组合拳确认边界

定位到候选函数后,动态验证一下函数边界非常有必要。Frida可以做两件精细的事:先Hook Java层的encrypt方法,记录传入的参数和返回值;再Hook native导出函数,观察JNI层的字符串内容。

直接上一段最小验证脚本:

Java.perform(function() { var EncryptUtils = Java.use("com.example.login.EncryptUtils"); EncryptUtils.encrypt.implementation = function(data, key) { var result = this.encrypt(data, key); console.log("encrypt(" + data + ", " + key + ") => " + result); return result; }; });

跑一次登录流程,观察控制台输出。如果result是一串hex,且和抓包里password一致,说明这个函数就是生成登录参数的核心函数。此时你可以验证自己刚才的猜测:参数里有没有时间戳、有没有nonce、key是什么。

如果函数不是导出名,而是动态注册的,也没关系,你可以HookRegisterNatives函数,把JNI方法表和native地址一次性dump出来:

var RegisterNatives = Module.findExportByName(null, "RegisterNatives"); Interceptor.attach(RegisterNatives, { onEnter: function(args) { var env = args[0]; var clazz = args[1]; var methods = args[2]; var count = args[3]; console.log("RegisterNatives count = " + count); } });

这个方法能拿到“函数名—函数地址”的映射,让后续静态分析有明确的入口点。一旦入口点确定,就可以关掉Java层,下钻到so内部。

注意:很多带OLLVM的so会做反调试,比如检测ptrace、检测Frida的/data/local/tmp/frida-server痕迹。真机调试时建议用spawn模式挂载,或者改用frida-gadget以library方式注入,减少被检测的概率。

4. 直面OLLVM混淆的静态还原

4.1 控制流平坦化的特征与破解思路

定位到目标函数之后,在IDA里F5,如果跳出来一个巨大的switch(state)式while循环,恭喜你,遇上了控制流平坦化。

它的典型结构如下。原始逻辑:

int check(int a, int b) { if (a > b) { return a - b; } else { return b - a; } }

被平坦化后大致长这样:

int check_obf(int a, int b) { int state = 0; while (1) { switch (state) { case 0: state = (a > b) ? 1 : 2; break; case 1: result = a - b; state = 3; break; case 2: result = b - a; state = 3; break; case 3: return result; } } }

所有的分支判断都被换成了“设置状态码”,然后由一个共同的dispatcher块来分发。这样原来if-else的顺序关系消失了,你看到的是一堆无关的case块。

要还原这种结构,静态分析的重点不是去读每个case里的代码,而是找出“状态变量”的赋值关系。通常沿着state = ...的赋值语句向上追,你会重建一张状态转移表。把state和对应的真实块对应起来之后,就能手工画出一个原始控制流图。

有个实用技巧:在IDA里把switch的跳转表dump下来,结合state值的变化记录,用Excel或Notepad简单整理成“状态0→分支条件→状态1/状态2”的映射表。大多数情况下,状态数不会特别多,几十个状态就能手动画完。别指望一步实现自动去平坦化,手工先跑通一个函数,掌握数据流比盲目上脚本更稳妥。

4.2 指令替换的识别与还原

控制流平坦化解决的是“流程顺序”,指令替换则是“计算形式”层面的迷惑。你可能会在代码里看到这种诡异的表达式:

v5 = (~v3 & v4) + (v3 & ~v4) + 1;

实际上这就是v3 ^ v4的一个复杂等价式。又比如x + y被写成x - (~y) - 1,都是同一个原理——在代数上恒等,在形式上把人绕晕。

识别指令替换的诀窍是“盯常数”和“盯返回值类型”。加密算法里的特征常量不会变。比如MD5的初始向量0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476,只要出现了,基本就是MD5系。SHA256初始值0x6a09e667也同样。当你的函数里出现了大量位运算和加法混合的代码,先别急着逐条换算,直接在Hex-Rays的伪代码窗口里搜索这些常量,往往瞬间定位到核心计算块。

对于纯粹的“恒等式替换”,我的做法是:先通过动态调试拿到一组明确的输入输出,再回看伪代码的最终结果表达式,把“中间绕的弯”全部砍掉。因为不管你怎么变形,最后算出来的值必须和运行时一致。用已知的输入输出校验还原后的等式是否正确,比猜原始表达式快得多。

4.3 不透明谓词与虚假块的剔除

虚假控制流是最恶心的干扰项。它会在真实逻辑路径之外,插入大量看起来真实但永远执行不到的代码块。这些假块内部往往也有完整的运算、内存访问,甚至函数调用,让你误以为存在关键逻辑。

如何判断一个块是不是假块?核心是看它进入条件中的“不透明谓词”。不透明谓词就是“你知道它永远是真/假,但编译器不优化掉”的判断。比如:

if ((2 * 7 - 14) == 0) { // 假块 } else { // 真块 }

在混淆版本里,这个谓词会被复杂的多项式或位运算包装起来,静态很难一眼看穿。但动态调试有一个优势:你只需要在真实输入上跑一遍,观察哪些块被实际访问了。Frida可以结合Stalker做指令级trace,把执行过的地址全部记录下来,没被访问的块基本可以标记为可疑假块。

实际操作中,我通常这样组合:先用Frida的Stalker跑一次登录流程,拿到真实执行地址表;回到IDA,用Alt+B批量打标记,把未执行块全部染成灰色;这样剩下的基本就是真实逻辑路径了。

提示:静态去平坦化脚本可以研究,但别迷信。工具在公开样例上效果好,一旦黑盒样本加了“花指令+多状态套娃”就很容易还原错误。动态trace数据永远是最可靠的滤网。

5. 动态验证与算法复原

5.1 Frida主动调用与参数提取

当核心函数确定后,下一步是建立“输入—输出”样本库。做法很简单,用Frida主动多调几次native函数,记录参数组合和返回结果。比如确认了sign是md5(nonce + ts + key)这种结构,但不知道key具体拼接位置时,你就可以用控制变量的方式试。

测试次数多一点,样本范围广一些。固定key,变化ts,观察输出是否随之变化;固定ts,改nonce,也观察变化。如果md5(nonce+ts+key)和md5(ts+nonce+key)输出不同,那拼接顺序你也就测出来了。

用Frida主动调用Java层native方法特别方便,直接:

Java.perform(function() { var EncryptUtils = Java.use("com.example.login.EncryptUtils"); console.log(EncryptUtils.encrypt("abc123", "1712345678")); });

多发几次,记录几十组数据。此时你不太需要理解内部的每条指令,你只需要把外部行为测清楚,这属于黑盒层面的“锁定算法框架”。

5.2 unidbg模拟执行与关键验证

黑盒样本再多,也避免不了“信息不够”的问题。比如算法里如果用了AES这类需要密钥扩展的分组密码,光靠黑盒是推不出内部密钥的。这时候unidbg就派上用场了。

用unidbg加载so,可以直接调用目标native函数,不需要真机,还可以在函数内部hook任意地址,看内存和寄存器情况。一个最小调用示例大概长这样:

emulator.loadLibrary("xxx"); DalvikVM vm = emulator.createDalvikVM(); vm.setJni(new JniInstance()); DvmObject<?> input = vm.resolveClass("java/lang/String") .newObject("abc123"); DvmObject<?> ret = module.callFunction(emulator, symbolAddress, input);

配置unidbg时常见的问题有两个:一个是so里调用了一些Android系统库函数,你需要补依赖;另一个是JNI_OnLoad可能在加载时注册一堆方法,需要提前把System.load流程打通。解决方式是先跑官方示例的loadLibrary和callJNI_OnLoad,再逐步补缺失的symbol。

我一般在unidbg里做两件事:第一,验证同一个输入在真机和模拟器上输出是否一致,一致说明环境没问题;第二,在算法关键地址下断点,dump中间状态,比如AES的轮密钥、MD5的链接变量。拿到中间状态后,再回到IDA静态分析里核对,几乎能一举确定算法实现细节。

5.3 用Python重写加密算法完成闭环

分析到这一步,你已经知道算法长什么样了。剩下要做的,就是把成果固化成一个独立可跑的实现。这一步的验证价值极大:如果你用同等逻辑重写的Python函数,面对同一输入能算出和App完全一样的输出,那你对算法的理解就站住脚了。

比如最终确认登录参数sign的逻辑是:

import hashlib def gen_sign(ts: str, nonce: str, key: str) -> str: raw = f"{ts}{nonce}{key}".encode() return hashlib.md5(raw).hexdigest()

又比如password是AES-CBC模式加密,key由固定字符串+ts哈希派生,那就按标准Crypto库实现,注意填充模式和IV来源。每写完一个函数,就和Frida采集的样本对比。一旦全量比对通过,整个登录参数分析就算闭环了。

注意:还原算法时最容易翻车的点是字节序和编码。JS/Java的字符串到C的char*,中间可能涉及UTF-8转码;hex字符串也可能先转成byte数组再参与运算。多一组“hex输出/byte数组输出”对照样本,能省很多排查时间。

6. 常见问题与排查技巧实录

6.1 常见问题速查表

整理一下这次分析过程中遇到的高频问题,做成一张速查表,方便以后直接翻。

现象可能原因排查与解决手段
Frida注入后App闪退反调试检测到了frida-server改用spawn模式或gadget注入,隐藏/data/local/tmp痕迹
Java层hook无输出native方法在子线程调用,hook时机太晚注册时机设为Java.perform+延迟100~200ms,或hookart::JNI入口
IDA F5结果全是switch控制流平坦化提取状态变量映射,手工搭建状态转移表
有大量相似但恒假的分支虚假控制流用Stalker跑trace,标记真实执行地址,过滤假块
unidbg加载so报mapping错误缺少依赖库或初始化路径不对检查AndroidResolver版本、补加载依赖so
算法重写后签名比对不一致编码、字节序、填充模式不一致从同一输入出发,dump中间变量逐段比对
函数找不到导出符号so被strip或动态注册HookRegisterNatives拉取JNI方法表
登录参数里有随机成分nonce或salt每次随机生成动态对比多次输出,分离可变字段和固定字段
日志里出现堆栈溢出Frida hook了递归调用函数加递归深度判断或改用Interceptor.replace

6.2 我的几条踩坑心得

第一个心得:动态调试的时间要舍得花。我见过不少同行在IDA里硬啃OLLVM的switch-case,一啃就是三天。其实用Frida先在函数边界上稳定拿到输入输出,再配合Stalker跑真实路径,最多半天就能把核心流程圈出来。静态分析做“深度”,动态分析做“广度”,顺序对了效率翻倍。

第二个心得:字符串常量是意外的宝藏。即使OLLVM把控制流搅得天翻地覆,很多算法常量还是会原样躺在.rodata里。搜到0x67452301,你基本可以断定某块代码和MD5有关;搜到0x6a09e667,那大概率是SHA256。搜索这些常量之后再回头看伪代码,局部还原的难度会骤降。

第三个心得:别轻视“输入样本”的价值。登录参数里的时间是动态变化的,nonce也会随机生成。我习惯在抓包后立刻用Frida把同一次请求对应的native函数参数和输出全部记录下来,并和抓包内容逐字段比对。有了这一组精确对照数据,后面的算法还原就像拿着答案找过程,而不是猜过程验证答案。

第四个心得:遇到加固+混淆双重保护时,先把加固壳的思路理清。很多App的so是“先加壳、后编译混淆”,分析时要先过一遍JNI_OnLoad,理清壳的加载流程,否则你看到的核心函数可能只是壳的代理函数。区分方法是看函数体积和调用关系——壳的代理通常逻辑极薄,真实逻辑还在后段或内存中解密后才加载。

最后再补充一个实用技巧:保留了所有抓包和脚本样本后,回头验证阶段用unidbg批量回归是最高效的方法。你写一个脚本,循环喂100组输入,断言输出全部一致,这一下就能把“是否真正还原”钉死。没有这个回归环节,只跑出两个样本一致就说算法分析完了,后面上线准会出问题。

我在实际分析中体会最深的一点是:OLLVM这类混淆本质上是在跟分析者拼“耐心和投入产出比”。它不会让函数逻辑变得数学上不可解,只是让“看清楚每一步”的成本变得极高。所以聪明的做法不是硬碰硬地全量还原,而是把分析目标收窄到“登录参数”这一个点上——只还原从输入到输出的路径,跳过所有无关分支。你不需要读懂so里每个函数,只需要把password和sign这两条数据流走通,任务就算圆满完成。希望这套从抓包、定位、静态还原到动态验证的流程,能在你下次碰到OLLVM时省下几个通宵。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询