【APP 逆向】哔哩哔哩 sign 参数逆向(下):OLLVM 混淆还原 sign 算法

声明

本文章中所有内容仅供学习交流使用,不用于其他任何目的,不提供完整代码,抓包内容、敏感网址、数据接口等均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关!
本文章未经许可禁止转载,禁止任何修改后二次传播,擅自使用本文讲解的技术而导致的任何意外,作者均不负责,若有侵权,请联系作者立即删除!

逆向目标

目标:哔哩哔哩 APP

apk 版本:8.0.0

下载地址:aHR0cHM6Ly93d3cud2FuZG91amlhLmNvbS9hcHBzLzI4MTI5MS9oaXN0b3J5X3Y4MDAwMjAw

工具总结

工具 用途
IDA Pro so 静态分析:反编译(F5)、查看函数边界(Alt+P)
Frida hook RegisterNatives 定位注册偏移;hook sub_18FF0 抓取入参
unidbg 模拟执行 so,trace 记录真实执行流程
AI 助手 分析 trace 日志,辅助解混淆并自动 patch
在线 MD5 工具 验证还原出的算法

逆向分析

在上一篇中,我们绕过了 frida 反调试,用 unidbg 黑盒调用拿到了 sign。但黑盒只告诉我们"能算",没告诉我们"怎么算"。这一篇,我们进入白盒,还原 sign 的生成算法。

一、静态分析

我们先把 libbili.so 拖进 IDA。

第一步,我们需要判断 s 方法是静态注册还是动态注册 。在 Exports 窗口按 Ctrl+F,搜索 com.bilibili.nativelibrary.LibBili.s------这是静态注册的命名规则,包名 + 类名 + 方法名。很明显,搜不到。

那么就是动态注册:Java 层的 native 方法并没有直接导出,而是在 JNI_OnLoad 里通过 RegisterNatives 动态绑定到 C 函数的。所以我们把目光聚焦到 JNI_OnLoad,直接在export中搜索,然后双击进入,按 F5 反编译。

反编译出来我们能看到,代码非常乱:大量的 while、switch、if 嵌套,所有变量名都变成了一串数字(v3、v8、v9)。像这种情况,就是典型的 OLLVM 混淆特征。

二、OLLVM 混淆

先看一张图,直观感受一下:

OLLVM = Obfuscator-LLVM,是一个基于 LLVM 编译器的代码混淆工具,主要给 C/C++ 等编译型语言用,能有效增加代码分析难度。libbili.so 里大量函数都用了它,核心特征有三个:

控制流平坦化(Flattening):把函数正常流程打散成多个基本块,加一个 switch 分发器,所有块都跳回分发器,由状态变量决定下一个块去哪。代码的"因果顺序"被彻底打乱。

虚假控制流(Bogus Control Flow):插入一些看起来会执行、实际上永远不走的条件分支,专门干扰人眼和静态分析工具。

指令替换(Substitution):把简单指令替换成复杂的等价表达式,比如把加法拆成移位、异或的组合。

对于 OLLVM,业界一般有两条路:

方案 适用场景 说明
静态解混淆(deflat / d810 等 IDA 插件) 标准 OLLVM 自动识别分发器,还原原始控制流
unidbg / unicorn trace + 手动 patch 定制 OLLVM 让代码真实跑一遍,记录执行日志,人工还原

哔哩哔哩这种商业级定制 OLLVM,我一开始尝试了 d810 插件,结果直接卡死(弹出 "Please wait..." 窗口一直转圈),最后只能强杀进程、把插件移到回收站、重新加载 IDA 数据库。所以这条路不可行,这里就不多说了,感兴趣的可以自己试试。

我们采用第二种方案:trace + patch

三、定位 s 函数

回到正题。JNI_OnLoad 虽然被混淆了,但我们其实不太关心它的流程------我们只关心 s 方法是在哪注册的、对应 C 层哪个函数。

先补个知识点,JNI 动态注册的标准写法:

c 复制代码
jclass clazz = (*env)->FindClass(env, "com/xxx/Dynamic");
int res = (*env)->RegisterNatives(env, clazz, gMethods, 2);

其中 gMethods 是 JNINativeMethod 结构体数组,定义了 Java 方法和 C 函数的映射关系:

c 复制代码
typedef struct {
    const char* name;      // Java 方法名,比如 "s"
    const char* signature; // JNI 签名,比如 "(Ljava/util/SortedMap;)..."
    void* fnPtr;           // C 层函数指针
} JNINativeMethod;

也就是说,只要拿到 gMethods,就能知道 s 在 C 层叫什么、在哪个偏移。那我们直接 hook RegisterNatives 就行。

RegisterNatives 是 ART 虚拟机(libart.so)的导出函数,任何 so 的 JNI_OnLoad 里调用它都会经过这里,我们拦截它,把每个 so 注册的方法全打印出来:

javascript 复制代码
function hook_RegisterNatives() {
    // 1. 枚举 libart.so 的所有符号
    var symbols = Module.enumerateSymbolsSync("libart.so");
    var addrRegisterNatives = null;

    // 2. 找 JRegisterNatives(排除 CheckJNI 的调试包装版本)
    for (var i = 0; i < symbols.length; i++) {
        var symbol = symbols[i];
        if (symbol.name.indexOf("art") >= 0 &&
            symbol.name.indexOf("JNI") >= 0 &&
            symbol.name.indexOf("RegisterNatives") >= 0 &&
            symbol.name.indexOf("CheckJNI") < 0) {
            addrRegisterNatives = symbol.address;
            console.log("RegisterNatives is at ", symbol.address, symbol.name);
            break;
        }
    }

    // 3. 找到后开始 hook
    if (addrRegisterNatives != null) {
        Interceptor.attach(addrRegisterNatives, {
            onEnter: function (args) {
                // RegisterNatives(env, clazz, gMethods, nMethods)
                var java_class = args[1];               // 第 2 个参数:Java 类
                var class_name = Java.vm.tryGetEnv().getClassName(java_class);
                // 只关心 LibBili 类注册的方法
                var taget_class = "com.bilibili.nativelibrary.LibBili";
                if (class_name === taget_class) {
                    console.log("\n[RegisterNatives] method_count:", args[3]);
                    // gMethods 数组:每个元素 3 个指针(name, sig, fnPtr)
                    var methods_ptr = ptr(args[2]);
                    var method_count = parseInt(args[3]);
                    for (var i = 0; i < method_count; i++) {
                        var name_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3));
                        var sig_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3 + Process.pointerSize));
                        var fnPtr_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3 + Process.pointerSize * 2));

                        var name = Memory.readCString(name_ptr);
                        var sig = Memory.readCString(sig_ptr);
                        var find_module = Process.findModuleByAddress(fnPtr_ptr);
                        // 关键:函数地址 - so 基地址 = 偏移量
                        var offset = ptr(fnPtr_ptr).sub(find_module.base)
                        console.log("name:", name, "sig:", sig, "module_name:", find_module.name, "offset:", offset);
                    }
                }
            }
        });
    }
}

注意一个细节:RegisterNatives 在 JNI_OnLoad 阶段就会执行完,所以这个 hook 必须赶在它之前挂上------用 spawn 模式启动 app 就没问题。

运行结果如下:

可以看到,name 为 s、签名是 (Ljava/util/SortedMap;)Lcom/bilibili/nativelibrary/SignedQuery; 的方法,在 libbili.so 中的偏移量是 0x9050。注意,这里打印的是偏移量,不是 C 层的函数名(函数名早被混淆没了)。

我们回到 IDA,按 G 输入偏移地址 0x9050 跳转:

跳转后发现,sub_9050 只是一个壳,反编译出来就一行:

c 复制代码
__int64 __fastcall sub_9050(__int64 a1, __int64 a2, __int64 a3)
{
    return sub_162A8(a1, a3, 0LL, 0LL);
}

它把参数整理了一下就转交给了 sub_162A8。我们点进 sub_162A8:

果不其然,sub_162A8 被严重混淆了,又是熟悉的 OLLVM。而这个函数,大概率就是最终生成 sign 的地方。接下来,我们需要解掉这个混淆。

四、unidbg trace 采集

再回顾一下 OLLVM 的套路:函数被拆成很多基本块,用一个状态变量 + switch 分发器重新编排执行顺序,还会故意走一些死分支------这些死分支不干实事,只是改一下状态变量、跳到下一个块,用来凑数。

也就是说,这些基本块实际分两类:一类是真实干活的计算指令(读写寄存器、加减乘除),另一类是跳转指令(特征:b / b.ne / b.eq)。我们要做的,就是清理掉假的跳转分支,把真实块串起来。

但问题来了:静态看根本分不清哪个块是真的、哪个是死的。所以思路反过来------让代码真实跑一遍。unidbg 模拟执行 s() 的时候,把 [begin, end) 区间内执行的每一条指令都记下来,这份"执行日志"就是还原流程的原材料。

第一步,确定 sub_162A8 的起止地址。在 IDA 里切到汇编视图,按 Alt+P 查看函数属性:

记录下两个关键值:Start 0x162A8、End 0x16CE8。注意 traceCode 是左闭右开区间,所以 end 要填下一个函数 sub_16CE8 的入口地址,这样它的指令一条都不会混进来。

第二步,在上一篇的 BiliSign.java 基础上,往 sign() 方法开头加 5 行代码:

java 复制代码
// 第 0 步:开 trace,记录 sub_162A8 执行过的每一条指令
PrintStream traceStream = new PrintStream(new FileOutputStream("trace_sub162A8.txt"), true);
long traceBegin = module.base + 0x162A8;   // sub_162A8 入口(Alt+P 查到的 Start)
long traceEnd = module.base + 0x16CE8;     // 下一个函数入口(Alt+P 查到的 End,左闭右开)
TraceHook mainTraceHook = emulator.traceCode(traceBegin, traceEnd);
mainTraceHook.setRedirect(traceStream);    // 日志写到文件,别刷屏控制台

其余代码和上一篇完全一样。运行,得到一个日志文件 trace_sub162A8.txt:

一共 724 行,sub_162A8 真实执行过的每一条指令都在里面了。

五、日志分析与 AI 解混淆

拿到日志,可以先学会怎么看。随便挑一行:

复制代码
139:[13:20:21 334][libbili.so 0x1662c] [81e9ff54] 0x4001662c: "b.ne #0x4001635c" nzcv: N=0, Z=1, C=1, V=0

把它拆成四段:

内容 含义
第一段 0x4001662c 这条指令的内存地址,每条指令的地址是唯一的
第二段 "b.ne #0x4001635c" 助记符:ARM64 汇编指令,意思是"不相等则跳到 0x4001635c"
第三段 [81e9ff54] 机器码:指令的十六进制字节,IDA 里看到的就是它,patch 时改的也是它
第四段 nzcv: N=0, Z=1, C=1, V=0 CPU 状态标志位:N 负数标志、Z 零标志、C 进位标志、V 溢出标志

人话版:程序当前在 0x1662c 这条指令上,它是一条条件跳转 b.ne。注意 Z=1------Z 是零标志,Z=1 表示上一次比较结果是"相等",而 b.ne 是"不相等才跳",所以这次跳转条件不成立 ,直接落到下一条指令。这种"执行了但跳不走的条件分支",就是 OLLVM 塞进来的干扰项

看到这里你可能觉得:怎么这么复杂,难道要一条条看?

其实不用。我们只需要知道这些基础特征,然后把混淆代码和 trace 日志一起丢给 AI,让它结合着分析还原。

这里还有个小插曲:AI 一开始 patch 总是报错,排查了半天,发现是它处理 ARM64 的 imm26 指令时字节偏移算错了 4 倍(把偏移除以 4 的处理写反了),修正后重新编码,25/25 个 patch 全部校验通过,产出一个新的 so 文件。原始 libbili.so 保持不变,patch 是打在副本上的。

用 IDA 重新打开 patch 后的 so:

可以看到,代码变得非常清爽了,没有了分发器和状态变量,只剩一条笔直的调用链:appkey_index_lookup(查找 appkey 索引)→ secret_table_select(按 16 字节对齐选表)→ sign_md5_generate(生成 MD5)→ NewStringUTF / NewObject(返回结果)。算法骨架一目了然。

六、定位 sign_md5_generate

我们从后往前看,最后一行是 NewObject,说明 C 层调用了 Java 层的 SignedQuery 类,new 了一个对象,把 sign 写了进去。

先补个知识点,C 调用 Java 构造对象的标准写法:

c 复制代码
// 1. 找到类
jclass cls = (*env)->FindClass(env, "com/utils/Foo");
// 2. 找到构造方法
jmethodID init = (*env)->GetMethodID(env, cls, "<init>", "(Ljava/lang/String;)V");
// 3. 实例化得到对象
jobject cls_obj = (*env)->NewObject(env, cls, init, (*env)->NewStringUTF(env, "文本内容"));

注意 NewObject 的最后一个参数,就是传入构造方法的值,也就是 sign 的内容。我们顺着它往上看:

红框标得很清楚:NewStringUTF 的返回值 v17 传给了 NewObject,而 v17 的内容来自 sign_md5_generate 的输出。这个函数就是算 sign 的地方。

点进 sign_md5_generate,把鼠标放到状态栏看它的偏移:

偏移是 0x18FF0。注意,hook C 函数和 hook 导出函数不一样,这种 sub_xxx 开头的是偏移地址,需要先拿到 so 的基地址再加偏移。hook 代码如下:

javascript 复制代码
function hook_18FF0() {
    const base = Module.findBaseAddress("libbili.so");
    Interceptor.attach(base.add(0x18FF0), {
        onEnter(args) {
            this.out = args[0];  // 保存指针,onLeave 用
            console.log("\n=== sub_18FF0 入参 ===");
            console.log("参数1   =", args[0]);
            console.log("参数2 =", args[1].readUtf8String());  // 完整 query 字符串
            console.log("参数3   =", args[2].toInt32());
            console.log("参数4:");
            console.log(hexdump(args[3], {length: 16, header: false}));
        },
        onLeave(retval) {
            console.log("\n=== sub_18FF0 返回值 ===");
            console.log("返回值 =", retval);
            console.log("sign   =", this.out.readUtf8String());  // 32 位 hex 字符串
            console.log("========================\n");
        }
    });
}

hook 的同时我们打开抓包,拿到 sign 值去日志里搜索对比:

结果非常清晰,四个参数逐一分析:

参数 1 是一个内存地址,先不管;参数 2 就是我们的请求体 (完整的 query 字符串);参数 3 是请求体的长度;参数 4 打印出来是一串 hex 数据,把它转成字符串,就是 560c52ccd288fed045859ed18bffd973------一个固定值。

到这里我们可以猜了:算法八成是把请求体和这个固定值拼起来,再整体 md5 一下。我们验证一下。

七、验证与结论

把"请求体 + 560c52ccd288fed045859ed18bffd973"拼起来,丢进任意一个在线 md5 网站:

结果 b968f79433a073a1b56944d371b204ed,和 hook 日志里打印的 sign 一模一样。

所以 sign 的算法就是:sign = md5(请求体 + 固定盐值 560c52ccd288fed045859ed18bffd973)

至此,sign 破解完毕。

总结

到这里,两篇文章连起来就是一个完整的逆向闭环:

第一篇黑盒:绕过 frida 反调试 → hook NewStringUTF 定位到 s() → unidbg 模拟执行,拿到 sign 但不知道内部逻辑;第二篇白盒:hook RegisterNatives 定位到 C 层偏移 → 发现 OLLVM 混淆 → unidbg trace 记录真实执行流程 → AI 辅助解混淆 → 顺着 NewObject 找到 sign_md5_generate → hook 拿到盐值 → md5 在线验证收工。

相关推荐
JacksonMx1 小时前
Java CompletableFuture 异步编程实战:从入门到架构师避坑指南
linux·数据库·python
半生过往2 小时前
前端学 Java 课程笔记
java·前端·笔记
闲研随记2 小时前
【文献阅读 ICLR 2026】RL算法:DECS
算法·llm·强化学习·iclr·rl
W_326002 小时前
Python-OpenCV HSV颜色空间与inRange颜色分割:按颜色提取目标物体
图像处理·人工智能·python·opencv·机器学习
量化小c2 小时前
一行代码查 BTCUSDT 和 AAPL 最新价?QuantDash 统一多市场实时行情接口实战
后端·算法·github
mmsx2 小时前
基于 Android 的校园信息管理系统源码
android·java·okhttp
机器人梦想家2 小时前
具身机器人视觉服务的异步回调与并发控制
数据库·算法·机器人
Buke..2 小时前
【APP 逆向】哔哩哔哩 sign 参数逆向(上):Frida 反调试绕过与 unidbg 调用
java·开发语言·爬虫·python·安卓
竹枝溪2 小时前
Tomcat与Servlet全套小白教程
java·servlet·tomcat·cookie·filter·session·listener