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