【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 脚本如下:

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

    // 2. 找 NewStringUTF 符号(排除 CheckJNI 包装版本)
    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;
    }

    // 3. attach 到该符号地址
    Interceptor.attach(addrNewStringUTF, {
        onEnter: function (args) {
            // NewStringUTF(JNIEnv* env, const char* utf): args[1] = char*
            try {
                var c_string = args[1].readCString();
            } catch (e) {
                return;
            }
            // 4. 只打 32 位长度的字符串(MD5 hex 特征)
            if (c_string && c_string.length === 32) {
                console.log("[NSU] " + c_string);
                // 5. 打印 native backtrace(看哪个 so 调用的)
                console.log(Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n') + '\n');
                // 6. 打印 Java stack trace(看哪个 Java 方法调用的)
                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 的特征(端口、线程名、内存中的特定字符串等)。

绕过原理:一般来说,有两种方式,一种是主动出击,一种是被动隐藏。

  • 主动出击,就是找到它检测的位置,赶在它检测开启之前把它 nop 掉。

  • 被动隐藏,就是利用 frida 的增强版,例如 strong_frida、huloda、florida 等等,隐藏 frida 的特征再去 hook。

哔哩哔哩的 app,我尝试了被动隐藏,比如使用 frida 增强版 florida 等,发现还是会被检测到(有兴趣的可以自己试试,我这里做的可能不够严谨)。

因此本文主要采用主动出击。

原理:app 启动时一般会在 so 加载中启动新的线程用于检测 hook(主线程用于更新 UI),所以我们只要找到哪个 so 文件加载了检测线程,然后趁它还没来得及启动就把它干掉。

在安卓中,so 文件通常是通过 dlopen 方法加载进来的,我们可以 hook 它,看看 app 到底加载了哪些 so 文件。dlopen 的原型如下:

c 复制代码
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 代码如下:

javascript 复制代码
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 函数,并查看创建的线程有哪些。

javascript 复制代码
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) {
            // args[2] 是线程函数 start_routine,看它属于哪个模块
            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 间接创建线程时,给它返回一个假线程,让它以为自己创建成功了。代码如下:

javascript 复制代码
function build_fake_pthread_create() {
    if (real_pthread_create === null) {
        // 保存系统真实的 pthread_create 地址(此时还没被替换,拿到的就是原版)
        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(欺骗成功)`);
            // 注意:这里不调用 start_routine,线程实际并未创建
            return 0; // pthread_create 成功返回 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) {
            // 当检测到 dlsym 返回 pthread_create 时,替换为假函数
            if (this.name === "pthread_create") {
                console.log("[dlsym] 检测到加载 pthread_create,正在替换为假函数...");
                if (fake_pthread_create === null) {
                    fake_pthread_create = build_fake_pthread_create();
                }
                // 关键:把返回值改成我们伪造的 NativeCallback 指针
                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 拦截");
                                // 只 attach 一次,用全局标志控制
                                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,并进行抓包。

javascript 复制代码
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 进去。

java 复制代码
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;

/**
 * 用 unidbg 模拟执行 bilibili 的 s() 签名函数。
 * 继承 AbstractJni:native 回调 Java 时优先反射执行 classpath 里的真实类,
 * 没实现的回调由 AbstractJni 兜底打日志,避免崩掉。
 */
public class BiliSign extends AbstractJni {

    public static AndroidEmulator emulator;   // 模拟出来的"手机"
    public static Memory memory;              // 模拟内存
    public static VM vm;                      // 模拟虚拟机(解析 apk、管理 Java 类)
    public static DalvikModule dm;            // so 被加载进虚拟机
    public static Module module;              // so 在内存中的模块(含 base 地址)
    public static DvmClass cLibBili;          // apk 里的 LibBili 类

    public BiliSign() {
        // 第 1 步:造一台 64 位"手机"(libbili.so 是 arm64-v8a,不能选 32 位)
        emulator = AndroidEmulatorBuilder.for64Bit()
                .setProcessName("tv.danmaku.bili")   // 进程名,模拟出来给 so 看的
                .build();
        memory = emulator.getMemory();
        // 第 2 步:设置 SDK 版本 + 系统库解析器(so 依赖的 libc 等按此定位)
        memory.setLibraryResolver(new AndroidResolver(23));
        // 第 3 步:创建虚拟机,把 apk 喂进去(SignedQuery、LibBili 类都定义在 apk 里)
        vm = emulator.createDalvikVM(new File("D:/study-programs/spider-projects/app-study/bilibili/哔哩哔哩_8.0.0.apk"));
        vm.setDvmClassFactory(new ProxyClassFactory()); // apk 里没有的类 → 自动生成代理,不报错
        vm.setVerbose(false); // true 会打印每一步 JNI 调用细节,排错时再打开
        // 第 4 步:加载 so
        dm = vm.loadLibrary(new File("D:/study-programs/spider-projects/app-study/bilibili/res/lib/arm64-v8a/libbili.so"), false);
        module = dm.getModule();
        // 第 5 步:拿到 LibBili 类(注意是斜杠分隔)
        cLibBili = vm.resolveClass("com/bilibili/nativelibrary/LibBili");
        // 第 6 步:执行 JNI_OnLoad(s() 是"动态注册"的,必须注册后才能调用)
        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");

        // JNI 方法签名:(参数类型)返回类型,用 JNI 描述符
        String methodSign = "s(Ljava/util/SortedMap;)Lcom/bilibili/nativelibrary/SignedQuery;";

        // 调用 s(),返回 SignedQuery 对象(ProxyDvmObject 把 Java 的 TreeMap 包装成 native 能操作的 jobject)
        DvmObject<?> obj = cLibBili.callStaticJniMethodObject(
                emulator,
                methodSign,
                ProxyDvmObject.createObject(vm, map));

        // 反射读取返回对象的字段(测试工程里没有 SignedQuery 类,无法直接强转)
        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)) 可以用以下写法完全替代:

java 复制代码
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。下一篇文章,我们从白盒角度彻底还原这个算法。

相关推荐
竹枝溪1 小时前
Tomcat与Servlet全套小白教程
java·servlet·tomcat·cookie·filter·session·listener
abcefg_h1 小时前
MCP 实战指南:如何在项目中使用 Model Context Protocol 及其通信原理
开发语言·后端·golang·mcp
头发够用的程序员2 小时前
ImportError: libopenblas.so.0: cannot open shared object file 完整复盘与解决方案
python·ubuntu
UseLessQQ2 小时前
云计算k8s集群部署
java·kubernetes·云计算
何以解忧,唯有..2 小时前
Python 线程编程:从入门到实战
开发语言·python
l1258652 小时前
# RAG上线评估指标体系:六大核心指标与压测实战全解析
数据库·人工智能·python·mysql·langchain·milvus
我命由我123452 小时前
人脸识别 - 人脸识别选帧
java·人工智能·python·算法·安全·java-ee·人脸识别
宠友信息2 小时前
IM系统开发技术路线分析,即时通讯源码助力快速搭建聊天应用
java·spring boot·redis·websocket·mysql·uni-app·vue