
接到一个登录接口鉴权逻辑的分析任务拿到手里的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 8jni解析上容易出幺蛾子。另外unidbg并不是所有so都能开箱即跑遇到JNI_OnLoad里主动注册了大量native方法的情况你需要提前把System.loadLibrary、dlopen这些路径打通必要时还要补JavaVM的虚函数表。提示分析前先把so文件丢进file命令看一眼是strip过还是带符号。如果是带符号版本那真是捡到宝OLLVM再混淆也一堆符号名给你指路。3. 定位登录逻辑的实操思路3.1 从网络层入手找突破口很多人一上来就扎进IDA里找函数那是本末倒置。正确顺序应该是先抓包搞清楚请求体里有哪几个字段、哪些是加密的、哪些是关联的。以这次登录请求为例抓包看到的POST体大概是usernameadminpassword9f9f2c1d...sign7a3b1e20...ts1712345678nonceabc123password明显是密文sign是一串定长的hexts是Unix时间戳nonce像是一个随机串。这里就能猜出几个关键点ts和nonce大概率是参与加密或签名计算的原材料sign可能是MD5/SHA系列的摘要也可能是HMAC。这种初步判断很有用它给了你后续hook的“靶子”。你已经知道要追踪tsnoncepassword的内容流而不需要去猜算法是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窗口搜一遍再AltT跳转到引用处往往就能找到注册表代码。第三看导入表。一个加密流程大概率会用到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用AltB批量打标记把未执行块全部染成灰色这样剩下的基本就是真实逻辑路径了。提示静态去平坦化脚本可以研究但别迷信。工具在公开样例上效果好一旦黑盒样本加了“花指令多状态套娃”就很容易还原错误。动态trace数据永远是最可靠的滤网。5. 动态验证与算法复原5.1 Frida主动调用与参数提取当核心函数确定后下一步是建立“输入—输出”样本库。做法很简单用Frida主动多调几次native函数记录参数组合和返回结果。比如确认了sign是md5(nonce ts key)这种结构但不知道key具体拼接位置时你就可以用控制变量的方式试。测试次数多一点样本范围广一些。固定key变化ts观察输出是否随之变化固定ts改nonce也观察变化。如果md5(noncetskey)和md5(tsnoncekey)输出不同那拼接顺序你也就测出来了。用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.replace6.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时省下几个通宵。