【APP 逆向】哔哩哔哩 sign 参数逆向(一):Frida 反调试绕过与 unidbg 黑盒调用
声明
本文章中所有内容仅供学习交流使用,不用于其他任何目的,不提供完整代码,抓包内容、敏感网址、数据接口等均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关!
本文章未经许可禁止转载,禁止任何修改后二次传播,擅自使用本文讲解的技术而导致的任何意外,作者均不负责,若有侵权,请联系作者立即删除!
逆向目标
目标:哔哩哔哩 APP
apk 版本:8.0.0
下载地址:aHR0cHM6Ly93d3cud2FuZG91amlhLmNvbS9hcHBzLzI4MTI5MS9oaXN0b3J5X3Y4MDAwMjAw
工具总结
| 工具 |
用途 |
| Reqable |
抓包,获取接口的请求参数 |
| JADX |
APK 反编译,静态定位 Java 层代码 |
| Frida |
动态注入,hook NewStringUTF / dlopen / dlsym 等函数 |
| unidbg(0.9.7) |
模拟 Android 设备,黑盒执行 so 中的算法 |
| IntelliJ IDEA |
运行 unidbg 测试工程 |
逆向分析
一、抓包
我们打开 reqable 进行抓包,在 app 中进行操作。这里演示的是 achives 接口,也就是哔哩哔哩创作者中心的作品页。

可以看到接口是 GET 请求,参数中有一个 sign 参数,这个就是我们需要分析的参数。
二、参数定位
我们打开 jadx,将 apk 拖入,然后搜索 sign,可以看到出现了非常多的结果。

为什么这么多?因为 sign 在代码里可能被用于校验、缓存、拼接,到处都有引用,一个个点击去看不现实。因此,我们先做一个假设:sign 参数是在 so 层生成,然后返回给 Java 层使用的。
如果假设成立,那么 so 层肯定需要将字符串转换后返回给 Java,此时会调用 NewStringUTF——这是 JNI 返回字符串的唯一必经之路。
我们可以对它进行 hook,并进行相关特征的匹配,例如 sign 的长度是 32 位(32 个十六进制字符,典型的 MD5 hex 特征)。hook 脚本如下:
function hook_NewStringUTF() {
var symbols = Module.enumerateSymbolsSync("libart.so");
var addrNewStringUTF = null;
for (var i = 0; i < symbols.length; i++) {
var symbol = symbols[i];
if (symbol.name.indexOf("NewStringUTF") >= 0 && symbol.name.indexOf("CheckJNI") < 0) {
addrNewStringUTF = symbol.address;
console.log("[+] NewStringUTF is at " + symbol.address + " (" + symbol.name + ")");
break;
}
}
if (addrNewStringUTF === null) {
console.log("[-] NewStringUTF symbol not found in libart.so");
return;
}
Interceptor.attach(addrNewStringUTF, {
onEnter: function (args) {
try {
var c_string = args[1].readCString();
} catch (e) {
return;
}
if (c_string && c_string.length === 32) {
console.log("[NSU] " + c_string);
console.log(Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n') + '\n');
try {
console.log(Java.use("android.util.Log").getStackTraceString(Java.use("java.lang.Throwable").$new()));
} catch (e) {
console.log("[!] Java stack trace failed: " + e);
}
}
}
});
}
运行它,然后这里出现了一个很奇怪的现象:用 spawn 模式打开 app hook 时,在 app 刚启动加载时会打印很多 NewStringUTF 的日志,当进入 app主页 之后会立即停止打印,之后无论做什么操作都不会再打印日志。

这一块卡了我很久。后来发现,原来是新版的哔哩哔哩增加了对 frida 的检测:
当使用 spawn 模式 hook 时,哔哩哔哩的 app 会自动重启,并且是在 0.5s 内,肉眼几乎无法察觉,然后 hook 就会失效。这也是为什么之前 hook 到一半就断掉了的原因。
当使用 attach 模式 hook 时,app 会直接闪退。
好,接下来第一个重难点:frida 反调试的绕过。
三、Frida 反调试绕过
先补个知识点。Android 系统加载一个 SO 库的顺序如下:
| 阶段 |
说明 |
| dlopen / android_dlopen_ext |
系统调用加载器,将 SO 映射到内存,获得基地址 |
| .init / .init_proc |
执行初始化段的代码 |
| .init_array |
执行初始化数组中的函数(C++ 全局构造函数等) |
| JNI_OnLoad |
最后执行,通常用于注册 JNI 方法 |
检测代码一般就埋在这条链路的某一步里——通常是加载完成前后启动一个专属线程,专门扫描进程里有没有 frida 的特征(端口、线程名、内存中的特定字符串等)。
绕过原理:一般来说,有两种方式,一种是主动出击,一种是被动隐藏。
哔哩哔哩的 app,我尝试了被动隐藏,比如使用 frida 增强版 florida 等,发现还是会被检测到(有兴趣的可以自己试试,我这里做的可能不够严谨)。
因此本文主要采用主动出击。
app 启动时一般会在 so 加载中启动新的线程用于检测 hook(主线程用于更新 UI),所以我们只要找到哪个 so 文件加载了检测线程,然后趁它还没来得及启动就把它干掉。
so 文件通常是通过 dlopen 方法加载进来的,我们可以 hook 它,看看 app 到底加载了哪些 so 文件。dlopen 的原型如下:
void *dlopen(const char *filename, int flag);
| 参数 |
说明 |
| filename |
so 文件的路径,例如 "libfoo.so" 或完整路径 /data/app/.../libfoo.so |
| flag |
加载选项:RTLD_LAZY 按需解析符号;RTLD_NOW 立即解析所有未定义符号;RTLD_GLOBAL 符号导出,可被后续库使用;RTLD_LOCAL 符号仅在本库内可见(默认) |
hook 代码如下:
function hook_dlopen() {
console.log("[+] Hook script loaded");
try {
const funcName = "android_dlopen_ext";
var funcPtr = Module.findExportByName(null, funcName);
if (funcPtr !== null && funcPtr !== undefined) {
Interceptor.attach(funcPtr, {
onEnter: function (args) {
var path = args[0].readCString();
if (path) {
console.log("[dlopen] " + path);
}
}
});
}
console.log("[+] hooked success!");
} catch (e) {
console.log("[-] Hook failed: " + e);
}
}
结果如下:

可以看到,程序运行到 libmsaoaidsec.so 就卡住闪退了。前面几十个 so 都正常通过,唯独卡在它这里——说明检测点就在这个 so 里。
在 Android 中,用于线程创建的方法一般为 pthread_create,因此,我们需要在 hook dlopen 加载 msaoaidsec 的同时,hook pthread_create 函数,并查看创建的线程有哪些。
function hook_pthread_create() {
var pthread_create_addr = Module.findExportByName("libc.so", "pthread_create");
console.log("pthread_create_addr: ", pthread_create_addr);
Interceptor.attach(pthread_create_addr, {
onEnter: function (args) {
console.log(args[2], Process.findModuleByAddress(args[2]).name);
}
});
}
但是 B 站做了反调试机制,hook pthread_create 是没用的,hook 不到东西(这里大家也可以自己去试试)。
经过实测,发现 B 站通过 dlsym 间接调用了 pthread_create 去创建线程。dlsym 可以通过获取函数的地址间接调用它,可以很好地防止被我们直接 hook。因此,我们可以尝试 hook dlsym 函数。

可以看到,B 站通过 dlsym 调用了 pthread_create。既然如此,我们可以尝试在它通过 dlsym 间接创建线程时,给它返回一个假线程,让它以为自己创建成功了。代码如下:
function build_fake_pthread_create() {
if (real_pthread_create === null) {
real_pthread_create = new NativeFunction(
Module.findExportByName(null, "pthread_create"),
'int',
['pointer', 'pointer', 'pointer', 'pointer']
);
}
return new NativeCallback(function (thread, attr, start_routine, arg) {
create_counter++;
if (create_counter <= MAX_FAKE) {
console.log(`[假pthread_create] 第 ${create_counter} 次拦截,返回 0(欺骗成功)`);
return 0;
} else {
console.log(`[假pthread_create] 第 ${create_counter} 次,放行给真实函数`);
return real_pthread_create(thread, attr, start_routine, arg);
}
}, 'int', ['pointer', 'pointer', 'pointer', 'pointer']);
}
function hook_dlsym() {
console.log("=== HOOKING dlsym ===");
Interceptor.attach(Module.findExportByName(null, "dlsym"), {
onEnter: function (args) {
this.name = ptr(args[1]).readCString();
},
onLeave: function (retval) {
if (this.name === "pthread_create") {
console.log("[dlsym] 检测到加载 pthread_create,正在替换为假函数...");
if (fake_pthread_create === null) {
fake_pthread_create = build_fake_pthread_create();
}
retval.replace(ptr(fake_pthread_create));
}
}
});
}
function hook_dlopen() {
console.log("[+] Hook script loaded");
try {
const funcName = "android_dlopen_ext";
var funcPtr = Module.findExportByName(null, funcName);
if (funcPtr !== null && funcPtr !== undefined) {
Interceptor.attach(funcPtr, {
onEnter: function (args) {
this.pathPtr = args[0];
if (this.pathPtr !== null && this.pathPtr !== undefined) {
try {
var path = this.pathPtr.readCString();
if (path && path.indexOf("libmsaoaidsec.so") !== -1) {
console.log("[dlopen] 已进入 libmsaoaidsec.so,激活 dlsym 拦截");
if (typeof globalThis.__dlsym_hooked === "undefined") {
hook_dlsym();
globalThis.__dlsym_hooked = true;
}
}
} catch (e) {
console.log("[!] Error reading path string");
}
}
}
});
} else {
console.log("[-] Warning: " + funcName + " not found in exports.");
}
console.log("[+] hooked success!");
} catch (e) {
console.log("[-] Hook failed: " + e);
}
}
fake_pthread_create 是一个 NativeCallback 伪造的假函数,签名和 pthread_create 完全一致。它维护一个计数器:前 N 次调用直接返回 0(pthread_create 成功时返回 0,即"线程创建成功"),但实际上没有创建任何线程;超过 N 次后放行给真实的 pthread_create,保证 app 的正常功能不受影响。而 hook_dlsym 的作用,就是在检测到 dlsym 返回 pthread_create 的地址时,把返回值替换成这个假函数——这样 so 里所有通过 dlsym 间接创建线程的调用,都会被假装返回成功。

可以看到 app 不再闪退重启,成功绕过了检测。
四、定位 sign 生成点
绕过反调试后,我们继续 hook NewStringUTF 方法,并且同时打开抓包,在包中提取 sign 值,去 hook 日志进行搜索。


可以看到成功打印了相关调用栈。我们从上往下看,可以看到首先是 libbili 的 s 方法,紧接着往上就是 signQuery 方法。我们顺着它打开 jadx 去查看。

可以看到这个 s 是一个 native 方法,传入了一个 map,我们的 sign 大概率就是在这里进行生成的。我们可以尝试对这个 s 方法进行 hook,并进行抓包。
function hook_s() {
Java.perform(function () {
var LibBili = Java.use("com.bilibili.nativelibrary.LibBili");
var originalS = LibBili.s;
LibBili.s.implementation = function (map) {
console.log("[s] Called");
var result;
try {
result = originalS.call(this, map);
} catch (e) {
console.log("[!] originalS.call failed: " + e);
result = originalS(map);
}
if (result != null) {
try {
var clazz = result.getClass();
var fields = clazz.getDeclaredFields();
for (var i = 0; i < fields.length; i++) {
var field = fields[i];
field.setAccessible(true);
var name = field.getName();
var value = field.get(result);
console.log(" " + name + " = " + value);
}
} catch (e) {
console.log("[!] Error reading result fields: " + e);
console.log(" result.toString() = " + result.toString());
}
} else {
console.log(" result is null");
}
return result;
};
console.log("[+] hook_s installed");
});
}
![]()
可以发现,s 方法传入的 map 就是我们的请求体,返回的内容中就有我们的 sign。


往上滑,可以看到 s 是在 libbili.so 中定义的。

我们解压 apk,在 lib 目录中找到 libbili.so(arm64-v8a)。

五、unidbg 调用
其实走到这里,我们已经可以将 sign 模拟出来了,也就是黑盒调用:既可以用 frida 主动调用,也可以直接用 unidbg 模拟执行拿到值。
本文采用 unidbg。
unidbg 是什么?一句话:unidbg 是一个 Java 开源项目,基于 unicorn 引擎,可以帮我们模拟一个 Android 或 iOS 设备,直接执行 so 文件中的算法,从而不需要再去逆向他内部的算法。
下载地址:Releases · zhkl0228/unidbg
下载之后,用 IDE 打开,然后在根目录新建一个 apks 文件夹,把安装包和 so 文件一起放进去。
![]()
然后在 unidbg-android 下方新建 src/test/java/com/nb/demo/BiliSign.java,把代码 copy 进去。
package com.nb.demo;
import com.github.unidbg.AndroidEmulator;
import com.github.unidbg.Module;
import com.github.unidbg.linux.android.AndroidEmulatorBuilder;
import com.github.unidbg.linux.android.AndroidResolver;
import com.github.unidbg.linux.android.dvm.*;
import com.github.unidbg.linux.android.dvm.jni.ProxyClassFactory;
import com.github.unidbg.linux.android.dvm.jni.ProxyDvmObject;
import com.github.unidbg.memory.Memory;
import java.io.File;
import java.util.TreeMap;
public class BiliSign extends AbstractJni {
public static AndroidEmulator emulator;
public static Memory memory;
public static VM vm;
public static DalvikModule dm;
public static Module module;
public static DvmClass cLibBili;
public BiliSign() {
emulator = AndroidEmulatorBuilder.for64Bit()
.setProcessName("tv.danmaku.bili")
.build();
memory = emulator.getMemory();
memory.setLibraryResolver(new AndroidResolver(23));
vm = emulator.createDalvikVM(new File("D:/study-programs/spider-projects/app-study/bilibili/哔哩哔哩_8.0.0.apk"));
vm.setDvmClassFactory(new ProxyClassFactory());
vm.setVerbose(false);
dm = vm.loadLibrary(new File("D:/study-programs/spider-projects/app-study/bilibili/res/lib/arm64-v8a/libbili.so"), false);
module = dm.getModule();
cLibBili = vm.resolveClass("com/bilibili/nativelibrary/LibBili");
dm.callJNI_OnLoad(emulator);
}
public void sign() throws Exception {
TreeMap<String, String> map = new TreeMap<>();
map.put("access_key", "【脱敏:发布前替换为真实值或打码】");
map.put("appkey", "1d8b6e7d45233436");
map.put("build", "8000200");
map.put("c_locale", "zh_CN");
map.put("channel", "alifenfa");
map.put("class", "is_pubing,pubed,not_pubed");
map.put("coop", "1");
map.put("disable_rcmd", "0");
map.put("mobi_app", "android");
map.put("order", "senddate");
map.put("platform", "android");
map.put("pn", "1");
map.put("ps", "20");
map.put("s_locale", "zh_CN");
map.put("statistics", "{\"appId\":1,\"platform\":3,\"version\":\"8.0.0\",\"abtest\":\"\"}");
map.put("ts", "1786939135");
String methodSign = "s(Ljava/util/SortedMap;)Lcom/bilibili/nativelibrary/SignedQuery;";
DvmObject<?> obj = cLibBili.callStaticJniMethodObject(
emulator,
methodSign,
ProxyDvmObject.createObject(vm, map));
Object signedQuery = obj.getValue();
String rawParams = (String) signedQuery.getClass().getField("rawParams").get(signedQuery);
String sign = (String) signedQuery.getClass().getField("sign").get(signedQuery);
System.out.println("rawParams=" + rawParams);
System.out.println("sign=" + sign);
}
public static void main(String[] args) throws Exception {
BiliSign biliSign = new BiliSign();
biliSign.sign();
}
}
至于代码,基本都是模板,这里就不多做解释了,我们只需要改一些特定的地方就可以直接运行:apk 路径、so 路径、想主动调用的方法(s)、传入的参数(请求体 map)等等。

可以看到运行后报错,我们只需要特别关注:
java.lang.UnsupportedOperationException create breakpoint : com/bilibili/nativelibrary/SignedQuery->r(Ljava/util/Map;)Ljava/lang/String;
还有:
java.lang.ClassNotFoundException Create breakpoint : com.bilibili.nativelibrary.SignedQuery
这个提示很明显:unidbg 在尝试运行 s 方法时,发现缺少 SignedQuery 这个类。对于 unidbg 的补环境,内容比较多,包括 C 在调用 Java 的某些方法时,可能会访问 Java JDK 或者 Android SDK 的一些内容,这些都是需要补的,具体大家可以去网上查询一些资料,这里不过多陈述。
因此,我们需要主动补给它 SignedQuery:直接在 jadx 中把 SignedQuery 代码全部 copy 出来,然后新建 src/test/java/com/bilibili/nativelibrary/SignedQuery.java 放进去,这样我们的 BiliSign 就可以直接访问它了。

紧接着我们会发现,SignedQuery 类报了很多错,其中大部分都是由于这个类访问了 Java 和 Android 中特有的方法,而在我们的 unidbg 中是没有的。接下来我们需要对这些报错一一进行处理。

比如第一个报错 TextUtils:这个类主要是用了 isEmpty 判断是否为空。TextUtils.isEmpty(CharSequence str) 的内部实现是 str == null || str.length() == 0,因此 if (!TextUtils.isEmpty(key)) 可以用以下写法完全替代:
if (key != null && key.length() > 0)
然后是 ascii 这个报错:在 SignedQuery 类中,通过 ASCII 访问了某个值,我们可以在 jadx 中找到这个位置,看它具体是什么——是一个固定值 15,那么我们直接写死就行了。


然后以此类推,把报错全部修复。

最后运行,成功出值,unidbg 黑盒执行成功。我们后续可以把它打成 jar 包,然后直接用 Python 调用就行。
总结
至此,第一篇就结束了。我们做了两件事:绕过了 frida 反调试(hook dlsym + fake pthread_create 欺骗),以及用 unidbg 黑盒调用拿到了 sign。
但黑盒只告诉我们"能算",没告诉我们"怎么算"——sign 内部的算法到底是什么?so 里藏着 OLLVM 混淆的 sub_162A8。下一篇文章,我们从白盒角度彻底还原这个算法。
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!