☰
OLLVM混淆下Android Native层登录参数逆向分析实战
2026/10/10 16:26:12 网站建设 项目流程

有段时间没碰native层的登录协议分析了,前几天朋友扔过来一个加固后的样本,说登录接口的参数在抓包工具里看着全是乱码,让我帮忙看看。我拿到手翻了一下午,发现是OLLVM混淆,而且是带控制流平坦化加指令替换的完整版。这篇文章就从这个样本出发,记录一下完整的分析思路和实操过程,算是给同样被OLLVM折磨过的人一份参考。

1. 先说清楚:OLLVM到底把登录参数藏在哪一层

很多人第一次接触OLLVM混淆的样本,习惯性先用Jadx看Java层代码,发现登录请求的参数字段全是加密的字符串,接着往native层一跟,直接就晕了。这不是你的问题,而是OLLVM的设计目标就是让人在汇编层面失去"线性阅读"的能力。分析登录参数之前,得先搞明白它动了几层手脚。

1.1 控制流平坦化:最影响分析效率的"跳楼机"

控制流平坦化是OLLVM最出名的pass,它把原本if-else、while、do-while这种有逻辑层次的控制结构,全部压平成一个大的状态机。原始代码里的每个基本块变成了状态机里的一个case,块和块之间不再直接跳转,而是先跳到统一的调度器,由调度器根据状态变量决定下一个该执行哪个块。

举个例子,原始的登录逻辑可能是:

if (username_len > 0) { encrypt_password(password); } else { return -1; }

经过平坦化之后,代码结构变成了:

while (state != EXIT) { switch (state) { case 0: state = (username_len > 0) ? 42 : 66; break; case 42: encrypt_password(password); state = EXIT; break; case 66: result = -1; state = EXIT; break; } }

光看这个还不够,真实样本里状态变量的计算往往掺杂了加法、异或、移位操作,比如state = (state * 7) ^ 0xABCD这种,你根本没办法直接从汇编里看出哪个块是处理密码加密的。分析登录参数时,难点不在于"看不到字符串",而在于你"不知道该看哪一段代码"。

1.2 指令替换和虚假控制流:把算术变成拼图

指令替换做的又是另一件事:把a = b + c这种简单指令,替换成一组等价但看起来很复杂的指令序列。比如加法可以被替换成:

a = (b ^ c) + 2 * (b & c);

或者更夸张的,直接内联一个查表逻辑。这导致你在反汇编窗口里看到的根本不是一个"加一条"指令,而是一长串看不出用途的运算。密码加密算法本来可能是很清晰的RSA调用,经过指令替换后,函数体变得面目全非,连识别算法的特征常数都费劲。

虚假控制流则是往原始代码里插入大量"永远不可能走到"的块。它用不透明谓词(比如x^2 >= 0这种恒真的条件)来构造跳转,让控制流看起来复杂无比,实际上那些分支永远不会被执行。但这些块会干扰你的人工分析,也会增大IDA等工具自动分析时的时间开销。

我刚拿到样本时,先用IDA加载了so文件,结果函数列表里有一堆函数的大小都是几十KB甚至上百KB,这就是典型的OLLVM特征——原本可能只有几百行代码的函数,被膨胀成了一个庞然大物。

提示:判断一个so是否用了OLLVM,不需要太复杂的分析,看两点就够:一是函数数量少但单个函数体积巨大,二是F5反编译出来的伪代码里有大量while加switch嵌套的模式。符合这两点,基本可以确定是OLLVM。

2. 入口定位:不碰汇编就先拿到明文骨架

面对OLLVM混淆,最忌讳的就是一头扎进汇编里硬啃。我自己的习惯是先绕开混淆层,从Java层和抓包数据里找到"目标函数的输入输出长什么样",再回到native层反向定位。这就像你要拆一个上了锁的保险柜,与其研究锁芯的每个弹子,不如先想办法听到密码转动的声音。

2.1 从Java层Hook拿到请求结构

登录接口的调用链通常是:Java层构造请求体 → 调用native函数加密 → 返回密文 → 拼接成完整请求发出。所以第一步是在Java层找到发请求的地方,把未加密前的参数结构完整dump出来。

我用的工具是Frida,配合一个很简单的脚本:

Java.perform(function () { var okhttp3 = Java.use("okhttp3.OkHttpClient"); okhttp3.newCall.overload('okhttp3.Request').implementation = function (request) { console.log("[+] Request URL: " + request.url().toString()); console.log("[+] Request Body: " + request.body().string()); return this.newCall(request); }; });

这段脚本能拦截所有OkHttp发出的请求,打印出URL和请求体。实测样本里,登录请求体里有一个字段是encrypted_data,里面是一串Base64密文,其他字段全是明文。这就明确了:登录参数的核心加密逻辑集中在native层,Java层的代码只是"调用了某个函数把参数加密成密文"。

接着我在Java层继续找谁调用了加密函数。用Jadx打开反编译代码,搜索"encrypted_data",很快就看到了一个工具类,里面有一个native方法的声明:

public static native String encryptRequest(String data, String key);

对应的so文件名是libnative-lib.so,JNI函数名是Java_com_example_secure_login_Encryptor_encryptRequest。到这里,已经确定了分析目标:在so文件里找到这个导出函数,从它开始往下分析加密流程。

2.2 导出函数映射与native层入口确认

用IDA加载libnative-lib.so,在导出表里搜索关键字"encrypt"或"Java",就能定位到对应的native函数。这一步不难,难点在于这个函数内部已经被OLLVM处理过,直接F5看到的伪代码基本没法读。

我记得当时的反编译结果是这样的:一个巨大的while循环,里面嵌套了超过200个switch case,状态变量由一个位字段数组维护,每个case块里还有大量看似无关的赋值运算。这种情况下,我不建议直接在反编译视图里逐行分析,而是先用动态的方式跑一遍,让程序自己告诉我们执行路径。

2.3 先看一眼反汇编:确认是否上了OLLVM

在动手写动态脚本之前,我会花几分钟时间快速浏览反汇编代码,确认混淆的类型和强度。打开IDA的Graph视图,如果看到的是一大堆红色跳转线织成一张蜘蛛网,基本就是OLLVM无疑。

我还会关注几个特征:

  • 函数开头是否有一段很长的"调度器"逻辑,通常是大量的cmp、mov、jmp组合
  • 是否频繁出现pushfq/popfq这种保存和恢复标志寄存器的操作——这是OLLVM部分版本处理标志位的方式
  • 是否存在大量跳转到0x00地址附近的基本块——这些可能是被填充的虚假块

这些特征确认后,就可以制定分析策略了。我的策略是:先动态hook拿到关键数据,再用静态脚本辅助还原逻辑,两者结合,而不是纯静态死磕。

3. 动态还原:用Frida把混淆"泄底"

动态分析的价值在于,不管OLLVM怎么把控制流打乱,程序真正执行的时候还是要老老实实地按真实路径走。我们能做的是在关键节点插入探针,观察数据的变化过程。数据不会骗人,尤其在加密参数分析中,输入输出和中间状态的变化往往比代码逻辑更容易揭示真相。

3.1 批量hook字符串与内存读取函数

OLLVM混淆一般不会把系统库函数也混淆掉,比如strlen、memcpy、malloc这些函数在so里仍然是直接调用的。通过hook这些函数,可以快速捕捉native层处理的数据内容。

我常用的脚本思路是批量hook libc里的常用函数:

# frida JS snippet var libc = Module.findBaseAddress("libc.so"); var mallocPtr = Module.findExportByName("libc.so", "malloc"); Interceptor.attach(mallocPtr, { onEnter: function (args) { this.size = args[0].toInt32(); }, onLeave: function (retval) { if (this.size > 16 && this.size < 4096) { console.log("[malloc] size=" + this.size + ", ptr=" + retval); } } });

同时对memcpy、strlen、strcmp做类似处理,记录源地址、目标地址和长度。然后手动触发一次登录,看调用了哪些函数、数据从哪里流向哪里。

实际跑下来,能看到一串memcpy调用的目标地址都在一个固定区间内,基本可以推测这是加密缓冲区在反复写数据。再结合后面对导出函数的hook,就能逐步定位出加密流程的骨架。

3.2 关键入参与返回值的抓取思路

对于Java_com_example_secure_login_Encryptor_encryptRequest这个函数,直接用Frida attach上去,打印入参和返回值:

var encryptAddr = Module.findExportByName("libnative-lib.so", "Java_com_example_secure_login_Encryptor_encryptRequest"); Interceptor.attach(encryptAddr, { onEnter: function (args) { var env = Java.vm.getEnv(); var jstr = Java.cast(args[2], Java.use("java.lang.String")); this.input = jstr.toString(); console.log("[+] encryptRequest input: " + this.input); this.keyStr = Java.cast(args[3], Java.use("java.lang.String")).toString(); console.log("[+] encryptRequest key: " + this.keyStr); }, onLeave: function (retval) { var env = Java.vm.getEnv(); var res = Java.cast(retval, Java.use("java.lang.String")); console.log("[+] encryptRequest output: " + res.toString()); } });

注意JNI函数的参数结构:第一个参数是JNIEnv*,第二个是jobject,从第三个开始才是Java方法声明里的参数。这里args[2]对应data,args[3]对应key。

跑一次登录后,能拿到一个很关键的结论:输入的明文格式是什么样的,输出的密文是什么样的。以我那个样本为例,输入是:

username=alice&password=123456×tamp=1700000000

输出是一段固定长度的Base64字符串,长度大约是输入明文的1.5倍左右,说明中间很可能有一次AES加密再Base64编码的环节。

3.3 借助unidbg做脱离设备的模拟执行

如果手上没有真机,或者App有非常强的反调试,动态分析会变得很困难。这时我一般会考虑unidbg,它是基于unicorn引擎的模拟执行框架,可以在PC上直接跑so文件的native函数。

unidbg对分析OLLVM混淆的帮助在于:它提供了一个纯软件的执行环境,可以精确控制去哪,还能打印每一条指令的寄存器状态。缺点是配置相对繁琐,需要处理so的依赖库、JNI环境、内存映射等一堆问题。但如果目标是"只关心某个函数输入输出+内部数据变化",unidbg其实比真机Frida更可控。

拿这个登录函数来说,unidbg会mock掉JNI环境,我们只需要用Java侧构造相同签名的调用:

public static void main(String[] args) { Emulator emulator = AndroidEmulatorFactory.create(); Memory memory = emulator.getMemory(); libraryResolver = new AndroidResolver(23); memory.setLibraryResolver(libraryResolver); VM vm = emulator.createDalvikVM(); vm.setJniEnv(new JniEnv()); DalvikModule module = vm.loadLibrary("native-lib", true); module.callJNI_OnLoad(emulator); byte[] input = "username=alice&password=123456×tamp=1700000000".getBytes(); byte[] key = "secret".getBytes(); // 调用encryptRequest DvmClass clazz = vm.resolveClass("com/example/secure/login/Encryptor"); DvmObject<?> obj = clazz.newObject(); DvmObject<?> result = obj.callJniMethodObject(emulator, "encryptRequest", input, key); System.out.println(result.getValue()); }

用unidbg跑一遍,能在PC上拿到和真机一致的结果,再配合它提供的指令级trace,就能看到加密函数里每一步在做什么。这个阶段的耗时主要在调试环境上,一旦跑通,后续的静态分析会轻松很多。

4. 静态脱混淆:用IDA Python清洗不可达块

动态分析拿到了清晰的输入输出,但中间步骤还是要靠静态分析来理解,毕竟动态只能看到"发生了",不能直接告诉你"为什么这么设计"。对于OLLVM,静态分析的突破口在于清洗掉那些虚假控制流插入的不可达块,把真正的逻辑从蜘蛛网里扒出来。

4.1 识别不透明谓词的特征

不透明谓词是虚假控制流的燃料。它们通常表现为一个恒真或恒假的表达式,比如(x * x) < 0永远不可能为真,但OLLVM生成代码时会把x * x做成一个运行时才计算的值,汇编层面看起来像个真实的条件分支。

在IDA里识别不透明谓词,我主要看两个特征:

  • 条件跳转的两个分支,其中一端最终会回到调度器主循环,另一端通往一个"孤岛"块,孤岛块里的运算结果不影响任何后续关键数据
  • 孤岛块里的指令模式高度相似,通常是几个算术运算加一个跳回主循环的指令

用IDA的脚本能力,遍历函数的所有基本块,统计每个基本块的出入度。正常情况下,被不透明谓词拉出来的分支形成的块,入度是1,出度也是1,并且从它出发能到达的路径很快会汇合到某个常见节点。这类块基本可以标记为"虚假块"。

4.2 清洗脚本思路与实测效果

我自己开发的思路不复杂:先用IDA的FlowChart和CFG遍历函数,收集所有基本块的控制流关系。然后找到那些"入度+出度都等于1、且指令序列里没有调用、没有内存写关键地址"的块,把它们从控制流图中剔除。剔除之后,再把跳转目标重定向到这些块的next块。

这里有个很关键的操作:剔除了虚假块之后,还要处理调度器的状态机。真正的数据流逻辑隐藏在每个case之间的距离关系中,但很多case其实只是"赋值新状态然后跳回循环",并没有实际操作。把这些状态转移块当作过渡,简化状态图,能进一步压缩代码体积。

我用IDAPython写了一个粗糙的脚本,对目标函数做了两轮清洗:

  1. 第一轮:删除所有不可达的孤立块
  2. 第二轮:合并所有"只赋值状态变量、不做实际运算"的转发块

清洗前函数大小是0x3800字节,清洗后变成了0x1400字节,缩水了60%左右。虽然还没到"和原始源码一样清晰"的程度,但反编译的伪代码已经能分辨出AES的SubBytes查表操作和RSA的大数运算特征了。

提示:不要指望清洗脚本能直接输出可读的C代码,它只是帮我们把干扰项去掉。真实分析还是需要结合动态hook的结果,人工判断数据流。清洗脚本能去掉噪音,但判断逻辑还得靠人。

4.3 等效还原后的关键数据流

清洗完OLLVM的干扰后,我对函数内的数据流做了一个粗粒度的还原:

  • 第一步:调用getDeviceInfo获取设备信息
  • 第二步:把用户名、密码、时间戳、设备信息拼成一个固定格式的字符串
  • 第三步:生成一个AES密钥,先用RSA公钥加密这个密钥
  • 第四步:用AES加密明文数据
  • 第五步:把RSA加密后的密钥和AES加密后的密文拼在一起,再做Base64编码返回

这个流程在OLLVM混淆的代码里看起来非常隐晦,但一旦还原出来,其实就是常见的"混合加密"方案。之所以这么设计,是为了让攻击者即使抓到了密文,也拿不到AES密钥,除非先解决RSA私钥的问题。

5. 登录参数逐个还原的完整样例

还原完数据流,剩下的事情就清晰多了:把每个字段的生成逻辑确认一遍,确保自己理解无误,最好还能自己在本地重写一版加密逻辑,让输出和App完全一致。这一步能验证前面的分析有没有出错。

5.1 账号字段的编码还原

登录参数里,用户名通常不是明文传输的。该样本里,username被转换成了Base64后再拼进请求体,看似做了"加密",实际上Base64只是编码,不是加密。

还原逻辑时,我在Java层confirm确认了用户名的编码方式:先用UTF-8取得字节,然后Base64编码。拿到这个结论后,自己构造一个同样编码的请求,和App的抓包结果对比,完全一致。这说明账号字段的"混淆"只是基础编码,真正的保护在后面的密码加密。

5.2 密码加密链路拆解(RSA+AES混合)

密码字段是整个登录参数里最核心的部分,也在OLLVM的加密流程中占据了最多的代码量。还原出来后的流程如下:

  1. 生成16字节随机数作为AES密钥
  2. 用RSA公钥加密这个16字节密钥,得到encrypted_key
  3. AES加密明文密码,得到encrypted_data
  4. encrypted_data的格式是:前16字节是随机IV,后面是密文

这样一来,每次登录即使密码相同,生成的密文也不同,因为AES密钥和IV都是随机的。从协议设计角度看,这个方案能有效防止重放攻击。

我通过Frida hook到了encrypted_key的长度,又通过静态分析确认了RSA公钥指数是65537,模长为1024位。有了这两个参数,我可以在本地用Python的pycryptodome库复现加密过程,验证输出与App一致。这一步完成的时候,密码加密链路就算彻底拿下了。

5.3 时间戳、随机数、设备指纹的生成逻辑

时间戳字段在登录参数里容易被忽视,但它往往承担着防重放的职责。这个样本里,时间戳是当前Unix时间戳的字符串形式,直接拼进了明文。App在服务端会验证时间戳是否在合理时间范围内,如果偏差超过5分钟就拒绝登录。这个做法的安全性一般,因为攻击者完全可以在构造请求时使用当前时间戳。

设备指纹稍微复杂一点,它由Android ID、MAC地址、Build信息拼接后取MD5得到。在native层获取设备信息,一是为了避免Java层被直接hook,二是为了在OLLVM的保护下增加分析难度。

我在还原设备指纹时,发现一个有意思的细节:MD5计算之前,字符串拼接顺序竟然是Build.MODEL + Build.BRAND + Build.MANUFACTURER,而不是常见的顺序。如果只看代码,很容易搞错拼写顺序。后来我是在静态分析里看到一个常量字符串列表,又对照着Java层的系统属性列表才确认的。

这个细节也说明了一个通用规律:native层混淆负责保护逻辑,但系统函数调用顺序和常量字符串仍然会泄漏线索。分析时多留意so文件的只读数据段,那些字符串往往能帮你理清逻辑顺序。

6. 环境对抗与反调试跳过

分析过程不是一帆风顺的,样本里的反调试检测让我多花了半天时间。如果你也遇到了类似问题,以下内容可以帮你少走弯路。

6.1 常见反调试手法与绕过

这个样本的反调试逻辑写在native层,检测了三种情况:

  • /proc/self/status里的TracerPid是否为0——如果非0说明被调试器附加
  • 检查当前进程名是否包含frida相关字符串
  • 检测/data/local/tmp下是否存在frida-server的文件名

前两种是常规操作,Frida也有对应的反检测手段,比如修改TracerPid的读取结果。第三种则比较朴素,通过改名frida-server就能绕过。

绕过反调试时,我的实践经验是:Frida的反检测模块与实际so库版本有兼容性问题,所以我不太依赖自动反检测,而是改用更low-level的方式——用ptrace自己附加父进程,让so的ptrace调用失败,从而让它误以为自己已经在被调试。这个方法对一部分反调试很管用。

6.2 动态调试中的"蜜罐"检测

除了直接检测调试器,样本里还埋了几个"蜜罐"逻辑:有些本来不会被执行的分支里故意放了崩溃代码,如果攻击者在调试时强行修改跳转条件去访问这些分支,程序就会crash。

这种设计的目的是给动态分析制造"误判":你以为发现了什么关键逻辑,结果只是混淆层埋下的陷阱。应对方法很简单——不要轻易改动跳转条件,先用真实的输入跑一遍,记录真正的执行路径,再针对路径上的节点做修改。

这一点其实也反映了OLLVM的核心理念:让攻击者无法确信自己看到的东西是真是假。所有静态看到的"可能性"都需要用动态来验证,而动态又会被蜜罐干扰。不过,任何混淆都有尽头,因为加密算法本身的数学逻辑是不变的。找到那部分不变的逻辑,就能从混淆中抽离出真相。

7. 我的体会与建议

这次登录参数分析,从拿到样本到完整还原加密链路,前后花了大约两天时间。一半时间耗在识别OLLVM混淆的类型和清洗控制流上,另一半时间花在动态验证和排除反调试的干扰上。

几个经验总结一下:

第一,OLLVM混淆的本质是提高分析的门槛,而不是加密本身。登录参数的安全性最终还是由RSA、AES这些标准算法保证,混淆只是让它们不那么容易被找到。所以分析OLLVM样本时,把重点放在"找到加密算法和密钥"上,比"理解每一行混淆代码"效率高得多。

第二,动态分析永远比纯静态省力。Frida加unidbg的组合,能快速拿到输入输出边界和关键中间值,缩小静态分析的范围。尤其对不透明谓词和虚假控制流,动态其实天然就能绕过它们——因为程序自己不会去执行虚假块。

第三,控制流洗净脚本是一把双刃剑。自动化清洗能提高效率,但清洗的质量直接影响后续分析结果。我自己写的脚本还远远谈不上通用,每个样本都需要手工调整参数。所以建议不要过度迷信工具,先学会读懂OLLVM的汇编特征,再谈自动化。

最后,这种分析能力在安全测试里确实能派上大用场。现在很多App在登录、支付等敏感接口上都上了OLLVM,如果你只能看Java层逻辑,基本等于放弃抵抗。这篇文章里写的是我个人的实操路径和心得,希望能给同样在研究这条路的人提供一点参考。碰到具体问题欢迎一起交流,毕竟混淆对抗这个领域,总是道高一尺、魔高一丈。

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

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

立即咨询