Bootstrap

APP逆向 day18某站逆向 part3

一.前言

今天来讲b站最后一部分,也就是最后这个心跳包的破解,这个是最有难度的,我这里给出要破解的地址

https://api.bilibili.com/x/report/heartbeat/mobile

二.抓包分析

这个就是我们要抓的包

请求头里的参数我们之前都破完了,我们直接来告诉大家请求体需要破解哪些东西

我们需要破解session和sign,那我们现在反编译apk,进行代码分析

三.破解session 

3.1 找到加密位置

我们搜索 x/report/heartbeat/mobile

发现两个位置,我们选择第二个,点进去 (第一个是test.cases,很明显是测试用的)

 我们进来之后,reportV2就是我们需要的,我们来进行查找用例

可以发现三个,这里我直接和大家说,第三个就是了

进来发现这个N7就是,那我们找到N7的赋值位置,双击进去

 我们再次双击进去

 发现这里是给一堆参数进行了赋值,第二个参数就是session,那我们返回上一步

所以 hVar.r1()就是session,我们点进去

发现this.d和他类实例化的位置

那我们对t2进行查找用例,查找后发现只有一个,那我们点进去

 所以我们的sessionid就是g.a.a(),我们再点进去

 至此,我们找到了session的位置

3.2 分析代码

首先构建一个字符串,然后添加了E.t(),System.currentTimeMillis(),random.nextInt(1000000)

E.t点进来

发现取得是buvid,之前破过

System.currentTimeMillis()就是时间戳

random.nextInt(1000000) 1000000以内的随机数

再进行sha1加密,我们可以hook一下

hook代码如下

import frida
import sys

rdev = frida.get_remote_device()
session = rdev.attach("哔哩哔哩")

scr = """
Java.perform(function () {

    let a = Java.use("com.bilibili.commons.m.a");
    a["i"].implementation = function (str) {
    console.log(`a.i is called: str=${str}`);
    let result = this["i"](str);
    console.log(`a.i result=${result}`);
    return result;
};

});
"""

script = session.create_script(scr)
def on_message(message, data):
    print(message, data)

script.on("message", on_message)
script.load()
sys.stdin.read()

hook到的结果一致 buvid:XX9BB5B98F807CD35BB98E5DDDBB84F9EF403 

 说明这个程序取出的buvid是空,故此不需要,验证发现就是sha1,sha1中也没有魔改也没有加盐

至此session破解成功

四.破解sign

4.1 找到加密位置

我们首先思考一下,刚刚的那一堆,是不是params是不是唯独没有sign,那我们就要想想sign他会怎么生成,很有可能就是对上面一堆字符串拼接后再进行加密,最后再对其进行加密生成sign再放进去,但是不管哪一个,都会有&sign=,所以我们直接搜索 "&sign=

发现有很多位置,这样一个个排除就很麻烦,我一会会说一个硬核破解,这个我就告诉你们,是

我们点进来,跟一遍 发现就在下面这里

这个this.b就是sign的值了,我们现在应该是要找this.b是在哪里传进来的,

发现就是在实例化这个方法的时候传进来的,我们理论上是要查找用例,看看这个类在哪实例化,但是我们找不到,因为在so层加密的,所以这个不可行,那我们还可以通过找到toString在哪调用的 

那我们来hook这个toString并且打印这个调用栈信息看看

import frida
import sys
from frida.core import Device
rdev = frida.get_remote_device()
session = rdev.attach("哔哩哔哩")
scr = """
Java.perform(function () {
    var SignedQuery = Java.use("com.bilibili.nativelibrary.SignedQuery");

    SignedQuery.toString.implementation = function(){   
       var res = this.toString();
       console.log(res);
       console.log(Java.use("android.util.Log").getStackTraceString(Java.use("java.lang.Throwable").$new()));
       return res;
    }

});
"""
script = session.create_script(scr)
script.load()
sys.stdin.read()

 我么通过抓包到的结果进行搜索

 发现下面这个,那调用栈也就是在这里了

com.bilibili.okretro.f.a.c(这个就是toString的上一步调用栈了,我们搜索一下)

也就是在这里 调用了tostring,那我们接着看看这个h做了啥事,我们点进去

在点进去看看

再点进去看看

发现是一个jni方法往上滑动

发现so文件时libbili.so

4.2 找到so中对应的函数

我们把apk变成压缩包再解压缩,用idea找到libbili.so

 进来发现是动态注册,因为没有java_,那我们进入jin_onload 按f5

那我么双击对应关系函数

 那我们双击进入

再次双击

这就是最后的加密方法 

4.3 找到sign的加密方法

我们滑到最后

发现这个就是c调用java中的方法

回顾一下之前的

再回顾一下c中调用java方法的写法

之前学过的 c调用java案例
1  找到类Foo
    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, "蔡徐坤"));

那这个v17不就是sign值么

v17 = a1->functions->NewStringUTF(a1, s); 读前文知道 v17不就是这个s么

到这个s,已经不好发现了,只能来猜测了

核心代码就是这一块,我们也就只能猜测了,

我们先点开,sub_227C

发现这个,173,-173这堆数字是不是有点眼熟呢,我们去浏览器搜索一下

 一看,md5,那我们一想想,sign的值很像md5,总结猜测,那是不是对前面的字符串进行md5加密,可能还会加盐进去,那sub_22B0是不是很有可能是加盐,我们点进去hook一下

这里和大家说一下这个sub_22B0不是函数的名字,而是函数的偏移量,所以我们hook不能采用名字的形式,而是偏移量,这个一会和大家讲硬核破解就知道了

这里给出hook代码

import frida
import sys

rdev = frida.get_remote_device()
session = rdev.attach("哔哩哔哩")
scr = """
Java.perform(function () {

    var libbili = Module.findBaseAddress("libbili.so");
	// hook sub_22B0函数,通过偏移量找到 偏移量 22B0
	var s_func = libbili.add(0x22b0 + 1); // 32位的so文件都要 +1
    console.log(s_func);

    Interceptor.attach(s_func, {
        onEnter: function (args) {
            // args[0]
            // args[1],明文字符串
            // args[2],明文字符串长度

            console.log("执行update,长度是:",args[2], args[2].toInt32());

            // console.log( hexdump(args[1], {length: args[2].toInt32()})  );
            console.log(args[1].readUtf8String())
        },
        onLeave: function (args) {
            console.log("=======================结束===================");
        }
    });
});
"""
script = session.create_script(scr)
script.load()
sys.stdin.read()

发现每次都是添加了这几个值,这个就是我们在里面加的盐

python验证

import hashlib
obj = hashlib.md5()
obj.update("actual_played_time=0&aid=1453997972&appkey=1d8b6e7d45233436&auto_play=0&build=6240300&c_locale=zh-Hans_CN&channel=xxl_gdt_wm_253&cid=1526814271&epid=0&epid_status=&from=7&from_spmid=tm.recommend.0.0&last_play_progress_time=0&list_play_time=0&max_play_progress_time=0&mid=0&miniplayer_play_time=0&mobi_app=android&network_type=1&paused_time=0&platform=android&play_status=0&play_type=1&played_time=0&quality=32&s_locale=zh-Hans_CN&session=eaaa503b653ac6e68134935caaf822a11139ce62&sid=0&spmid=main.ugc-video-detail.0.0&start_ts=0&statistics=%7B%22appId%22%3A1%2C%22platform%22%3A3%2C%22version%22%3A%226.24.0%22%2C%22abtest%22%3A%22%22%7D&sub_type=0&total_time=0&ts=1716390021&type=3&user_status=0&video_duration=195".encode('utf-8'))
obj.update("560c52cc".encode('utf-8'))
obj.update("d288fed0".encode('utf-8'))
obj.update("45859ed1".encode('utf-8'))
obj.update("8bffd973".encode('utf-8'))
res = obj.hexdigest()
print(res)

# 期望结果:f57ee9b9235576d09846ad8453462037
#真实结果: f57ee9b9235576d09846ad8453462037

至此,我们找到了sign的加密

五.硬核破解sign 

5.1 hook——NewStringUTF

1 分析
    -因为:HeartbeatParams 构造方法没有sign
    -搜索:"&sign=  搜索---》确实搜到了,也定位到了代码--》过程复杂
        -以后有的app,可能没搜到
        
    -如果搜不到&sign=,多半是sign的加密不在java层,而在so层
        -如果在so层--》c中函数--》如果返回的是字符串--》一定会调用 NewStringUTF
    
2 写一个通用脚本--》hook--》 NewStringUTF
    -很多位置会用到它--》NewStringUTF
    -通过某些限定条件,过滤
        -1 返回的字符串中 包含固定的某些字符串  sign  ..
        -2 返回字符串是固定长度---》今天的案例就是32位长度 

这里给出通用的hook脚本

// 后期任何app,都可以使用这个代码--》hook--so返回的字符串

//1 加载安卓手机底层包,系统自带的库,我们hook的NewStringUTF在这个包中
var symbols = Module.enumerateSymbolsSync("libart.so");
//2 定义一个变量,用来接收一会找到的NewStringUTF的地址
var addrNewStringUTF = null;
//3 循环找出libart.so中所有成员,匹配是NewStringUTF的函数,取出地址,赋值给上面的变量
for (var i = 0; i < symbols.length; i++) {
    //3.1 取出libart.so的一个个方法对象
    var symbol = symbols[i];
    //3.2 判断方法对象的名字是不是包含 NewStringUTF和CheckJNI---》因为在真正底层,函数名不叫NewStringUTF,前后有别的字符串
    // 实际它真正的名字:asdfa_NewStringUTF_dadsfasfd
    if (symbol.name.indexOf("NewStringUTF") >= 0 && symbol.name.indexOf("CheckJNI") < 0) {
        // 3.3 找到后,把地址赋值个上面的变量
        addrNewStringUTF = symbol.address;
        // 3.4 控制台打印一下
        console.log("NewStringUTF is at ", symbol.address, symbol.name);
        break
    }
}
// 4 如果不为空,我们开始hook它(通过地址hook,有onEnter和onExit,所有的参数都给了args,通过位置取到每个参数)
if (addrNewStringUTF != null) {
    Interceptor.attach(addrNewStringUTF, {
        onEnter: function (args) {
            // 4.1 取出NewStringUTF传入的第一个参数
            var c_string = args[1];
            // 4.2 第一个参数是c的字符串,我们把它转一下,变成真正的字符串
            var dataString = c_string.readCString();
            // 4.3 改字符串不为空,且长度为32,我们输出一下,并且打印出它的调用栈
            if (dataString) {
                if (dataString.length === 32) { //后期只要改这里 代表筛选条件
                    console.log(dataString);
                    // 4.4 读取当前在执行那个so文件,及so文件中的地址
                    console.log(Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n') + '\n');
                    // 4.5 打印调用栈
                    console.log(Java.use("android.util.Log").getStackTraceString(Java.use("java.lang.Throwable").$new()));
                }
            }

        }
    });
}

// frida -UF -l 18.通用hook_NewStringUTF.js
// 末尾加上-o v1.txt代表日志输出到文档

通过抓包到的sign搜索

 第二个是调用的so文件,下面就是调用栈,通过这个就能找到,这个和我们之前分析的是一样的,大家也可以跟一下

5.2 Hook-RegisterNatives

这里给出一个通用脚本,这个的作用就是找到动态注册中的全部对应关系,这个对我们十分方便我这里给出hook代码以及详细注释,后期直接用,只需要改类名就好

function hook_RegisterNatives() {
    //1 加载安卓手机底层包,系统自带的库,我们hook的RegisterNatives在这个包中
    var symbols = Module.enumerateSymbolsSync("libart.so");
    //2 定义一个变量,用来接收一会找到的addrRegisterNatives的地址
    var addrRegisterNatives = null;
    // 3 循环找到RegisterNatives的地址,赋值给变量
    //注意:此处可能找出多个RegisterNatives的地址,由于咱们是for循环,会把之前的覆盖掉,所有如果hook没反应,尝试加break,使用第一个找到的
    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
        }

    }
    // 4 找到后开始hook
    if (addrRegisterNatives != null) {
        Interceptor.attach(addrRegisterNatives, {
            // 4.1 当进入RegisterNatives时执行
            // RegisterNatives(env, 类型, Java和C的对应关系,个数)
            onEnter: function (args) {
                // 4.2 第0个参数是env
                var env = args[0];
                // 4.3 第1个参数是类型
                var java_class = args[1];
                 // 4.4 通过类型得到具体的类名
                var class_name = Java.vm.tryGetEnv().getClassName(java_class);
                //console.log(class_name);
                // 只有类名为com.bilibili.nativelibrary.LibBili,才打印输出
                //后期只需要改这里,这里就是在jadx找到so文件下的类名
                var taget_class = "com.bilibili.nativelibrary.LibBili";
                if (class_name === taget_class) {
                    //4.5  只有类名为com.bilibili.nativelibrary.LibBili,再取出第四个参数
                    console.log("\n[RegisterNatives] method_count:", args[3]);
                    // 4.6 第2个参数是:Java和C的对应关系,我们转成指针
                    /*
                    static JNINativeMethod gMethods[] = {
                            {"add", "(III)I", (void *) plus},
                            {"add", "(II)I", (void *) plus},
                            {"add", "(II)I", (void *) plus},
                    };
                     */
                    var methods_ptr = ptr(args[2]);
                    // 4.7 java和c函数对应关系的个数
                    var method_count = parseInt(args[3]);
                    // 4.8 我们循环这个个数,依次移动指针methods_ptr,通过readPointer,往后读取 {"add", "(III)I", (void *) plus},依次读出Java中函数名字,签名和C中的函数指针
                    for (var i = 0; i < method_count; i++) {
                        // 4.8.1 读取Java中函数名字的
                        var name_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3));
                        // 4.8.2 读取签名, 参数和返回值类型
                        var sig_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3 + Process.pointerSize));
                        // 4.8.3 读取 C中的函数指针
                        var fnPtr_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3 + Process.pointerSize * 2));

                        // 4.8.4 读取java中函数名 字符串名
                        var name = Memory.readCString(name_ptr);
                        // 4.8.5 参数和返回值类型 字符串名
                        var sig = Memory.readCString(sig_ptr);
                        // 4.5.6 根据C中函数指针获取模块
                        var find_module = Process.findModuleByAddress(fnPtr_ptr); // 根据C中函数指针获取模块


                        // 4.8.7 得到该函数的偏移量:ptr(fnPtr_ptr)函数在内存中的地址   减去   该so文件的基地址(find_module.base)====得到偏移量
                        // 地址:函数在内存中的地址
                        // 偏移量:后期单独打开so文件后,可以根据偏移量 定位到函数位置
                        // 基地址:当前so文件从那个位置开始算地址
                        var offset = ptr(fnPtr_ptr).sub(find_module.base)
                        // console.log("[RegisterNatives] java_class:", class_name);
                        // 4.8.8 输出 函数名      参数和返回值类型    模块    偏移量
                        console.log("name:", name, "sig:", sig, "module_name:", find_module.name, "offset:", offset);

                    }
                }
            }
        });
    }
}

setImmediate(hook_RegisterNatives);

// frida -U -f 包名字 -l 19.Hook动态注册的对应关系.js
// frida -U -f tv.danmaku.bili -l 19.Hook动态注册的对应关系.js

结果是这个,偏移量是0x1c97

我们现在就在idea中

点击上面这两个

输入偏移量后

进入到这里,这就是我们找对应关系找到的地方,完美,有了这个就很方便了

六.总结 

因为之前被封,所以很久才更新,今天这个主要是后面的两个hook脚本很重要,app逆向是真的难,真的难找,很大一部分比的就是大家的hook代码

补充

有需要源码的看我主页签名名字私信我,有求必应

;