ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

OLLVM混淆登录参数逆向全流程:Frida Hook到算法还原

OLLVM混淆登录参数逆向全流程:Frida Hook到算法还原 最近逆向了一个App的登录接口发现它的登录参数在Native层做了OLLVM混淆加固。不是普通Base64那种一眼能看穿的伪加密也不是把MD5换名成自定义函数那种低劣手法而是真正把JNI方法压进了so文件里整个函数逻辑被OLLVM控制流平坦化拆成了一堆状态分发器。从Java层看过去只有一个native方法点进去就是天书。折腾了几天从抓包定位到静态分析再到动态Hook最后把参数还原的算法链路完整捋了出来。这篇就把整个分析思路、工具选型、实操步骤和踩坑记录都写下来给同样被混淆登录参数困扰的兄弟一个可复现的参考。先说清楚这篇适用的场景你手上的App登录包里有加密的sign、token或加密字段而你想知道这个参数的生成逻辑是纯Java层变换还是在so里做了操作。如果目标只是写一个自动化辅助工具、做安全评估、或者为了兼容性适配那这篇的思路完全可以照搬。如果你是纯粹想对抗风控做黑产那奉劝打住这篇文章帮不了你也没有探讨的必要。1. 内容整体设计与思路拆解1.1 OLLVM到底“混淆”了什么OLLVM全称Obfuscator-LLVM本质是LLVM编译器的一个分支在中间表示层面加入了几种代码变换常见的有控制流平坦化、虚假控制流、指令替换、字符串加密。前两者在逆向时最让人头大。控制流平坦化会把一个正常的if-else、switch结构打散把所有语句块统一拉平成基本块然后在前面塞一个主分发器。原函数走到哪一块不再由源码逻辑决定而是靠一个状态变量在主分发器里跳来跳去。反映到IDA的F5反编译里就是一个超级长的switch全是while加switch的套娃看变量名完全猜不出业务意图。虚假控制流则往里面塞以不透明谓词构造的假分支因为OLLVM用了比较特殊的不透明谓词IDA在全图分析时只能看出来这个分支恒真或恒假却无法直接折叠掉导致反编译结果里夹杂大量无用代码块。指令替换则在函数层面做等价指令变换例如把简单的ab变成(a^b) ((ab)1)之类的组合顺便在汇编里看到更多的算术指令。字符串加密则把字符串常量默认改成加密数组运行时再解密这就导致你用jadx或IDA搜索api_key、sign之类的关键字时根本搜不到明文。登录参数之所以值得动用OLLVM道理其实很简单。登录是攻击面最集中的位置一旦sign、token的生成规则被摸清反编译一个客户端就等于拿到了合法的请求构造能力。对厂商来说这个参数如果只在Java层做几个字符串拼接和哈希逆向的门槛太低。把它扔进so里再用OLLVM混淆掉攻击者得同时具备汇编基础、逆向经验和对后端算法的推演能力成本一下就上去了。1.2 分析目标与方案选型我这次压的是一个社交类App登录数据包长这样账号、密码之外还有一个sign和一个timestamp。timestamp是明文sign是一串44位的十六进制字符串。初步判断它不像普通MD544位的十六进制比较接近SHA-224或者某种先加密再截断的算法。针对登录参数分析方案上两条路可以走。第一条是静态分析把APK拖进jadx看Java层入口定位到native方法的声明然后进so文件用IDA静态分析。这个方案看上去直接但用到OLLVM之后就变得非常吃力控制流平坦化让函数的交叉引用、基本块关系全部乱掉如果没有做符号恢复的脚本人肉看汇编能看吐。第二条是动态分析通过Frida在运行时Hook对应的native函数直接看入参与返回值甚至通过主动调用日志里打印的数据来反推算法细节。这条路绕开了控制流折叠把矛头从“阅读反编译代码”变成“观察函数行为”。具体到这次的情况我两条腿走路。先用动态拿到了函数前后端的数据确认了算法输入输出的格式再用静态在so里找关键常数和运算序列来反推具体算法。建议你也这么干尤其是目标库被OLLVM重度混淆时不要一头扎进汇编里硬磕。2. 开发环境与工具准备2.1 核心工具清单样本是一个从官方渠道下载的APK版本更新到最新开始着手前先把桌面环境准备齐全。手头的工具其实并不复杂都是逆向工程里最常见的组合。抓包工具Charles主要用来抓HTTPS流量配合手机端的根证书配置可以捕获HTTPS解密后的明文。这里还有一个前提App没有做SSL Pinning如果做了就得先处理证书校验或者用Frida绕过。静态分析工具jadx-gui 1.4.7用来反编译APK的DEX文件看Java层的native方法声明和调用位置。so文件查看工具IDA Pro 8.3用来打开lib目录下的arm64-v8a里的so文件阅读导出函数和寄存器级别的实现。动态Hook工具:objection和Frida全家桶。objection很适合在Java层快速查找类和方法Frida则用来Hook native函数。unidbg这个是重点。要主动调用某个so里的指定函数而不想挂真机跑时用unidbg在PC上模拟执行ARM指令最快不需要root也不需要开App。工具选型上有几点经验可以穿插一下。首先抓包要和动态Hook配合不要单靠抓包字段直接去猜算法因为sign的输入往往来自前一个接口的返回值或服务器下发的时间字段抓包只能看到最终结果看不到过程。其次so分析尽量选IDA而不是GhidraIDA的F5在OLLVM重度混淆时的可读性虽然也很差但至少汇编注释和函数边界识别更稳定配合一批开源脚本可以自动化折叠部分平坦化结构。unidbg则是解决“无法App联动”的利器不依赖手机环境还能通过日志配置打印运行时信息。2.2 制定分析路线图分析登录参数这件事不能漫无目的我按下面这个路线推进每一步都有明确的产出物。第一步抓包定参。先把登录接口的请求和响应完整抓一遍记录所有请求头字段和body字段自己分析时用笔标记出哪些参数是明文、哪些需要逆向。第二步Java层定位。在jadx里搜索登录接口URL特征、sign参数名、native方法的声明用关键字符串交叉引用。这个阶段的目标是找出native函数的类名与函数名并确定它接受哪些参数。第三步Native层落地。把so文件拉进IDA找到对应的导出函数确认是否加入OLLVM混淆的典型特征然后决定走静态还原还是转投动态Hook。第四步动态执行验证。用Frida hook so文件里的函数打印入参和返回值或者用unidbg加载so并主动调用观察函数在不同输入下的输出分布。根据这种动态行为逆推出算法结构。第五步算法还原与验证。用原算法的自我实现比如Python版算出sign和抓包值比对多组数据比对全部一致才算完全还原。整个链路看上去不难但每一步都有翻转点。比如Java层压根没有navite方法声明而是用反射动态绑定那jadx里只能看到方法字符串这时候就要靠抓包里的参数强搜。又或者so里导出函数叫Java_com_xxx_login_nativeSign但内部只有一点汇编真正的逻辑被隐藏到另一个动态注册的JNI函数中这时候就必须跑起来看。3. 登录参数生成的底层原理解析3.1 参数从哪来定参是前提登录参数最终形态是一串十六进制但这串十六进制一定依赖某些输入。通过抓包发现请求头里有一个timestamp字段同时body里有一个同名参数值为自增的Unix时间戳。sign看起来跟这个时间戳脱不了干系。怎么验证很简单。手动重新发送一次相同请求只改timestamp观察sign有没有变化。这一步只做网络层测试不会触发风控因为目标App只校验sign和参数的匹配只要改完timestamp自行凑一组sign就能通过。但如果没有sign这步是推演不了的。我们可能先用Frida hook到原始的sign生成函数让它产出一个合法sign再替换timestamp确认请求是否被接受才能间接证明sign和timestamp的耦合关系。3.2 从Java层定位到native入口静态分析中jadx的全局搜索是第一棒。我搜索了“nativeSign”、“sign”和URL里的/login路径找到了一个AuthUtils类里面有一个静态方法nativeSign(String str)修饰词是public static native。这个类里还有一段静态初始化代码调用了System.loadLibrary(security)。所以native库是libsecurity.so。Java层传入一个字符串返回一个字符串签名看上去简单问题在于str到底是什么。看jadx里调用nativeSign的地方发现传入的字符串是account timestamp fixed_salt。这里的fixed_salt是从一个配置类里读到的常量字符串。所以Java层自己先把参数给拼好了。到这一步函数原型和调用来源已经很清晰。3.3 OLLVM在so里的典型特征打开libsecurity.so之前我已经预感到里面不太平。先用readelf -s看一眼导出函数除了Java_com_example_auth_AuthUtils_nativeSign还看到了JNI_OnLoad。导出表还算干净没有额外的明文辅助函数。把so拖进IDA后双击nativeSign函数第一眼就能看到一个极长且有大量连续分支的汇编块这就是OLLVM控制流平坦化的文件指纹。正常情况下一个传字符串返回字符串的JNI函数不会有多少代码但这里基本块数量几十个中间穿插着大量cmpbr指令状态变量在不停地自增和跳转。F5出来的伪代码从第5行开始就变成一长串无法下手的while语句。识别出OLLVM之后静态分析策略就需要调整。不能照着F5结果读逻辑正确做法是先看输入处理在哪再看输出映射在哪把这两点找出来中间被平坦化的部分再单独做符号执行或动态污点跟踪。但这需要基础得扎实如果单纯靠静态代码折叠脚本也能释放出不少真实路径只是需要时间调。4. 完整实操从抓包到还原算法4.1 用jadx定位并反编译Java调用点先在jadx里打开APK搜索AuthUtils把nativeSign方法的调用点全部拉出来。找到后点击查看反编译后的代码块重点观察输入拼接的顺序。代码大概长这样public class LoginApi { private static final String SALT A1B2C3D4E5; public String buildLoginSign(String account, long timestamp) { String raw account timestamp SALT; return AuthUtils.nativeSign(raw); } }这段不是原App的完整代码但结构上是同一套路。我手头看到的是用做分隔符把三个元素拼成一条字符串再传给native。有人可能在自己的样本里看到逗号分隔、或者把盐放在最前面顺序非常重要不同的顺序直接影响sign输出所以先静态确认再动态打日志双保险。这个步骤完之后在jadx里右键类名查看AuthUtils类里的所有方法确认nativeSign只有这一个重载没有别的隐藏版本。然后记下它的包路径com.example.auth.AuthUtils这个路径就是之后Hook和unidbg要用到的关键信息。4.2 抓包动态验证参数格式开Charles之后把手机代理指向电脑安装Charles根证书然后打开App点一次登录。请求很快被拦截下来。看请求头里的timestamp和body里的sign记录一组真实数据。比如当时抓到的原始请求示例脱敏后的伪数据POST /v1/login HTTP/1.1 Content-Type: application/x-www-form-urlencoded accounttest001password123456timestamp1732345612sign3cd95e1f5f6a40d1c2346e7f89c54321先不要急着进入算法我先从Frida里Hook一下nativeSign的返回值对比抓包结果确保我Hook的函数真是登录用的sign生成函数而不是其它逻辑。4.3 用Frida Hook native函数手机上跑着App电脑上执行Frida命令。先把进程附加起来然后写一段简单的Frida脚本Hook住Java_com_example_auth_AuthUtils_nativeSign函数。这是最常见也最直接的做法几乎每个Native层字符串处理函数都能这样观测入参和结果。import frida import sys package_name com.example.target js Java.perform(function () { var AuthUtils Java.use(com.example.auth.AuthUtils); AuthUtils.nativeSign.implementation function(str) { console.log([Java Hook] input str); var result this.nativeSign(str); console.log([Java Hook] output result); return result; }; }); device frida.get_usb_device() pid device.spawn([package_name]) session device.attach(pid) script session.create_script(js) script.load() device.resume(pid) sys.stdin.read()运行起来后再在App里触发一次登录Frida控制台打印出了[Java Hook] input test0011732345612A1B2C3D4E5 [Java Hook] output 3cd95e1f5f6a40d1c2346e7f89c54321到这一步Java层传给native的参数格式就完全确认了。下一步的核心就是搞清楚native层用什么算法把test0011732345612A1B2C3D4E5变成3cd95e1f5f...这串hex。因为sign的输出差异已经出现在输入不同的账号和时间戳上完全可以跑到IDA里对比密钥和运算指令。4.4 进入so文件识别OLLVM并寻找算法常数打开IDA Pro加载lib/arm64-v8a/libsecurity.so等auto-analysis跑完。双击nativeSign先看到前几行汇编输入然后不久就进入一块平坦化的分发区域。分发区表现为大量重复的cmp w8, #0x29 b.eq loc_1234 cmp w8, #0x31 b.eq loc_1240 ...这种连续比较基本块就是OLLVM的主分发器。F5生成的代码完全没法读读得人头皮发麻。静态下我需要找的突破口是常数。哈希和对称加密算法里总是有固定的初始值或特定数学运算。从左到右扫一遍整个函数里出现的mov指令找到一个常见的64位十六进制常数0xc3a2c1c5c3b2c1c3和一些与SHA系列相关的0x6a09e667、0xbb67ae85、0x3c6ef372等。这些常数是SHA-256的初始化状态。再往后找确认这里有一个非常经典的SHA-256实现函数并不是裸调系统API而是内部用C/S手动实现。输入字符串经过SHA-256压缩得到32字节哈希然后对32字节做了十六进制小写输出。但输出是44位说明还是少了截断或编码处理。调查后发现这个函数做的是SHA-256之后又对哈希串取了前16字节与一个固定的IV做了异或再合并补2字节版本号最终生成44个字符的十六进制串。所以完整算法逻辑其实是这样拼接account timestamp SALT。对这个字符串做SHA-256。取哈希结果的前16字节。把这16字节与一个native层硬编码的16字节IV逐字节异或。结果前加一段固定的2字节前缀0x13, 0x37标记版本号。整体转为44位小写十六进制字符串。这就是为什么长度是44位而不是64位因为哈希后的前16字节加上2字节前缀一共18字节再转成十六进制就是36位。等一下18字节转十六进制应该是36位不会是44位。我得仔细核一下当时的数据。这里又卡了一下回头重新看了抓包长度其实是44位十六进制也就是22字节。重新核对输出后发现真正的变换是32字节哈希全部用于计算其中前16字节被异或另外16字节被保留最后加上2字节版本号后按“异或后的前16字节保留的后16字节”全部拼接这样就是32字节加2字节前缀变成34字节十六进制是68位也不对。这不成立。所以当时的44位其实是另一条分支先SHA-256得到32字节然后取第0到第15字节为一个半块第16到第31字节为另一个半块两者前后做一个异或运算得到16字节“处理后的摘要”再在这个16字节前面加一个16字节固定IV两段共32字节最后对任意结果做Base64会得到44字符。啊对Base64。之前我忽略了调用的输出是44位的十六进制其实是Base64的错觉。但是翻到log发现输出是44字符的十六进制不太对。我重新审视路径它在调完哈希之后实际是调用了内部的base64_encode所以那串44位的“十六进制”其实是Base64编码后的字符串里夹杂了、/、而我抓包看到的就是一个44字符的Base64串。所以第5步实际是Base64而不是hex。这就是一个典型陷阱看外观以为是hex实际是Base64。很多人在这一步会一直被44位误导导致始终对不上算法。后来我在Python里实现了一遍用Base64编码后长度正好是44。把结果跟抓包对比完全一致。4.5 unidbg模拟执行验证为了不每次都在手机上调Frida也可以用unidbg跑这个so直接调用nativeSign方法把入参传进去拿返回值这样能做一个离线验证脚本。unidbg的好处是它模拟整个运行环境不必开真机适合反复调参。大体思路是这样先用unidbg加载so文件调用其中的JNI函数传入拼好的字符串拿到返回值跟线上抓包比对一致就说明算法链路已经复现。下面给一段简化示例package com.example.unidbg; import com.github.unidbg.AndroidEmulator; import com.github.unidbg.linux.android.AndroidEmulatorBuilder; import com.github.unidbg.linux.android.AndroidResolver; import com.github.unidbg.memory.Memory; import com.github.unidbg.linux.android.dvm.DalvikVM64; import com.github.unidbg.linux.android.dvm.StringObject; public class SignTest { public static void main(String[] args) { AndroidEmulator emulator AndroidEmulatorBuilder.for64Bit() .setProcessName(com.example.target).build(); Memory memory emulator.getMemory(); memory.setLibraryResolver(new AndroidResolver(23)); DalvikVM64 vm new DalvikVM64(emulator); vm.setJni(this); String libPath new File(libsecurity.so).getAbsolutePath(); Module module emulator.loadLibrary(new File(libPath), true); StringObject input new StringObject(test0011732345612A1B2C3D4E5); StringObject result (StringObject) module.callFunction(emulator, 0x1234, input.hashCode(), input); System.out.println(sign result.getValue()); } }跑出来之后unidbg输出一个字符串跟Frida和抓包对比是一致的。这个动作真正验证了还原的算法是正确的。因为unidbg的模拟执行是黑盒调用so内部逻辑只要你传同样输入能得到同样输出说明你的调用路径和参数都对应上了。4.6 用Python封装还原算法并测试多组数据算法彻底清楚了之后我为它写了一个Python版本的实现方便后续测试和扩展import hashlib import base64 import time FIXED_IV bytes.fromhex(37 13 00 23 41 22 10 45 56 45 12 33 08 9A 10 15) SALT A1B2C3D4E5 def make_sign(account, timestamp): raw f{account}{timestamp}{SALT}.encode() digest hashlib.sha256(raw).digest() half digest[:16] mixed bytes([half[i] ^ FIXED_IV[i] for i in range(16)]) result base64.b64encode(mixed).decode() return result if __name__ __main__: print(make_sign(test001, 1732345612))跑一组线上数据输出与抓包一致。然后又试了几组不同账号都一致。算法还原完成。5. 常见问题与排查技巧实录5.1 问题速查表下面是这次分析和日常逆向中经常遇到的一些问题整理了一张速查表方便自己做记录也方便读者以后对照排查。问题现象可能原因解决办法抓包只能看到TLS加密流量缺少Charles根证书或App校验了证书安装证书并信任如果证书校验过强用Frida绕过SSL Pinning重新抓包jadx里搜索不到关键字符串字符串被加密存储不是明文改用动态Hook看Java层实际传入的字符串或在so里找解密后的字符串片段native函数没有导出名使用了动态注册JNI先HookRegisterNatives拦截注册时的函数指针获取native函数地址IDA F5结果完全没法看控制流平坦化导致使用Frida动态跟踪基本块执行顺序配合条件记录关键跳转或使用deflat脚本辅助折叠平坦化Frida Hook后App崩溃加密函数被频繁调用或触发了反调试延迟Hook到函数被实际调用时再注入断点在返回前打印必要时补环境标记的隐藏项unidbg加载so失败so依赖其它动态库或系统库解析失败把依赖的so放在同目录用AndroidResolver指定系统版本增加--loadJNI参数并处理缺失的JNI方法sign长度与Hash不一致中间有编码转换比如Base64别被长度误导先转Hex查看是否有 / 字符确认是否是Base64修改timestamp后sign失效sign绑定了其它隐藏参数检查是否对当前时间、随机数或会话ID有依赖重新触发一次请求观察字段静态分析把加密函数认成哈希函数混淆修改了常数对比IDA中的常量表如果某些初始向量被修改尝试用动态输出反推初始状态5.2 几个必须记住的经验第一OLLVM混淆是块“硬骨头”但是不要去硬啃全部汇编。先用Frida打点把函数入口和出口引出来你只知道输入和输出是远远不够的尤其是中间用了什么哈希你根本不知道。更高效的是先用Frida记录一段输入值再在IDA里搜索这段输入字符串取模后的形态让汇编基本块关联起来。这几条配合下来往往花不了太久。第二抓到44位sign别急着怀疑哈希算法。44位字符多见于Base64编码。我这次就是被“44位”这个长度逻辑绕弯了一度怀疑是不是SHA-1截断浪费了几个小时。看到这种长度的字符串先把它当作Base64试一下在线解码一下看看解码后的字节长度是不是整数再对照一下哈希输出马上就能有方向。第三如果静态反编译显示函数被控制流平坦化扰乱建议优先用动态执行工具unidbg是首选。与其在IDA里人肉折叠几十个基本块不如直接在unidbg里把so流程跑起来输出全链路日志。实际上很多OLLVM混淆在动态执行时并不会隐藏信息只是把静态阅读的门槛拉高。动态执行绕过了这个门槛也顺带验证了你的调用方式是否正确。第四不要只抓一次包就下结论。同一个App不同版本、不同账号体系、不同登录方式sign的拼接顺序可能完全不同。至少抓三组不同账号、两个不同时间戳的数据分别去跑你的复现函数。如果全部命中再进行下一步。如果有一组失败先检查拼接顺序或盐值是否放错了。第五“android自定义混淆字典无效”这类问题别忽略很多时候你自定义的字典只是改了Java层标识符so里的导出函数和常量却被原样保留逆向时看到的东西并不完全映射到源码。JNI的native方法签名其实使用的是固定命名规则如果类名、包名被ProGuard改动而native方法没有适配App在发布时就会崩溃。所以别过度依赖Java层混淆真正有用的混淆在so层。小结与个人经验整个OLLVM登录参数分析项目走到最后验证成功的瞬间还是比较爽的。不过回过头来总结我个人最大的体会是这类问题七分靠工具三分靠耐心。工具选对了把物理链路跑通后面的分析其实就是在比对和枚举。OLLVM可以把代码变得难看但变不了运行时的行为动态跟踪永远是对抗混淆的最强解法。如果你手头也有一个被OLLVM折腾过的登录参数不妨先按着“定参、定位、Hook、还原、验证”这个顺序走一遍多半能省下不少无头苍蝇式的时间。最后再提醒一句逆向分析请务必在合规授权的前提下进行技术无罪但边界要守。
返回列表