一.前言
今天来讲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代码
补充
有需要源码的看我主页签名名字私信我,有求必应