一、Bilibili
1. 前置
[!sana]
版本:6.24.0
包名:tv.danmaku.bili
so文件:libbili.so
目标:增加视频播放量算法
2. 抓包分析
- 使用socksdroid转发+charles抓包即可;
- 一共两个接口比较重要:
https://api.bilibili.com/x/report/heartbeat/mobile
https://api.bilibili.com/x/report/click/android2
- 抓包示例如下:
- 第一个是心跳包,熟悉的朋友可能知道,控制完播率这方面的,剩下一个就是增加播放量的;
3. click android2接口
- 首先看一下接口需要哪些参数:
请求地址:
https://api.bilibili.com/x/report/click/android2
请求方式:
POST
请求头:
buvid XY88A4185B06248EA82BB0101C2ADC5DAD33B
device-id bFpoC2lebA9uWm5ZJVkl
fp_local d9c996c3fb98c1808bbf64ee054dda5420241222161236dee9d93eca085cd19e
fp_remote d9c996c3fb98c1808bbf64ee054dda542024112221174303fb9f17f667dc29f3
session_id f815bbd9
请求体:
ùouO\#âèhA^>o(](©ú······Ã<xxÚý\@aèyd
是一串乱码样式的内容
3.1 请求体分析
- 直接反编译搜索地址的后部分即可,这样比较唯一,搜索到后直接点过去;
- 这里可以发现心跳接口其实也在这里,并且它是一个接口,那就去找它的实现,右键查找用例或者按x,也只有一个位置,点进去看看;
- 这里其实也跟明了,看不懂的可以去了解一下retrofit,这里调用reportClick方法,传入了create这个参数,参考接口定义的位置,这个变量应该就是请求体;
- 那么我们就应该去看create这个参数怎来的;
public final void a() {
long j2;
long i = c2.f.f.c.j.a.i() / 1000;
c2.f.b0.c.b.b.a.a E = c2.f.b0.c.b.b.a.a.E();
x.h(E, "EnvironmentPrefHelper.getInstance()");
long A = E.A();
if (A == -1) {
c2.f.b0.c.b.b.a.a E2 = c2.f.b0.c.b.b.a.a.E();
x.h(E2, "EnvironmentPrefHelper.getInstance()");
E2.V(i);
j2 = i;
} else {
j2 = A;
}
c0 create = c0.create(w.d(com.hpplay.sdk.source.protocol.h.E), d.this.H7(this.b.a(), this.b.b(), this.b.h(), i, j2, this.b.n(), this.b.m(), this.b.k(), this.b.c(), this.b.e(), this.b.l(), this.b.f()));
x.h(create, "RequestBody.create(Media…ion/octet-stream\"), body)");
l<String> execute = ((tv.danmaku.biliplayerimpl.report.heartbeat.a) com.bilibili.okretro.c.a(tv.danmaku.biliplayerimpl.report.heartbeat.a.class)).reportClick(create).execute();
int b = execute.b();
String h = execute.h();
BLog.i("HeartBeatTracker", "player report click(vv): responseCode:" + b + ", responseMsg:" + h + ", responseBody:" + execute.a());
}
- 分析它的源码;
x1 = w.d(com.hpplay.sdk.source.protocol.h.E)
x2 = d.this.H7(this.b.a(), this.b.b(), this.b.h(), i, j2, this.b.n(), this.b.m(), this.b.k(), this.b.c(), this.b.e(), this.b.l(), this.b.f())
c0 create = c0.create(x1,x2);
- 其实他就两个参数,第一个参数是一个请求格式的常量,那么第二个参数看着这么长,就有可能是我们需要的请求体参数了;
- 进入H7方法看看;
public final byte[] H7(long j2, long j4, int i, long j5, long j6, int i2, int i3, long j7, String str, int i4, String str2, String str3) throws Exception {
long j8;
int i5;
Application f2 = BiliContext.f();
com.bilibili.lib.accounts.b client = com.bilibili.lib.accounts.b.f(f2);
AccountInfo h = BiliAccountInfo.f.a().h();
if (h != null) {
j8 = h.getMid();
i5 = h.getLevel();
} else {
j8 = 0;
i5 = 0;
}
TreeMap treeMap = new TreeMap();
treeMap.put("aid", String.valueOf(j2));
treeMap.put("cid", String.valueOf(j4));
treeMap.put("part", String.valueOf(i));
treeMap.put(EditCustomizeSticker.TAG_MID, String.valueOf(j8));
treeMap.put("lv", String.valueOf(i5));
treeMap.put("ftime", String.valueOf(j6));
treeMap.put("stime", String.valueOf(j5));
treeMap.put("did", com.bilibili.lib.biliid.utils.f.a.c(f2));
treeMap.put("type", String.valueOf(i2));
treeMap.put("sub_type", String.valueOf(i3));
treeMap.put("sid", String.valueOf(j7));
treeMap.put("epid", str);
treeMap.put("auto_play", String.valueOf(i4));
x.h(client, "client");
if (client.r()) {
treeMap.put("access_key", client.g());
}
treeMap.put("build", String.valueOf(com.bilibili.api.a.f()));
treeMap.put("mobi_app", com.bilibili.api.a.l());
treeMap.put("spmid", str2);
treeMap.put("from_spmid", str3);
StringBuilder sb = new StringBuilder();
for (Map.Entry entry : treeMap.entrySet()) {
String str4 = (String) entry.getValue();
sb.append((String) entry.getKey());
sb.append('=');
if (str4 == null) {
str4 = "";
}
sb.append(str4);
sb.append('&');
}
sb.deleteCharAt(sb.length() - 1);
String sb2 = sb.toString();
x.h(sb2, "builder.toString()");
String b2 = t3.a.i.a.a.a.b.e.b(sb2);
BLog.i("HeartBeatTracker", "player report click(vv), params: " + sb2 + " & sign=" + b2);
sb.append("&sign=");
sb.append(b2);
String sb3 = sb.toString();
x.h(sb3, "builder.toString()");
return t3.a.i.a.a.a.b.e.a(sb3);
}
- 看起来很庞大,但是貌似也有些比较敏感的参数,这个方法的主要目的是构建一个包含多个参数的请求,对这些参数进行签名,然后将签名后的参数字符串转换为字节数组,它会把map的参数逐一添加到sb中,中间以&符号拼接;
- 这里很明显就是看这个sign嘛,去看看b方法;
public final String b(String params) {
x.q(params, "params");
Charset charset = com.bilibili.commons.c.b;
x.h(charset, "Charsets.UTF_8");
byte[] bytes = params.getBytes(charset);
x.h(bytes, "(this as java.lang.String).getBytes(charset)");
String str = d;
Charset charset2 = com.bilibili.commons.c.b;
x.h(charset2, "Charsets.UTF_8");
if (str != null) {
byte[] bytes2 = str.getBytes(charset2);
x.h(bytes2, "(this as java.lang.String).getBytes(charset)");
String g = com.bilibili.commons.m.a.g(bytes, bytes2);
x.h(g, "DigestUtils.sha256(param…yteArray(Charsets.UTF_8))");
Locale locale = Locale.US;
x.h(locale, "Locale.US");
if (g != null) {
String lowerCase = g.toLowerCase(locale);
x.h(lowerCase, "(this as java.lang.String).toLowerCase(locale)");
return lowerCase;
}
throw new TypeCastException("null cannot be cast to non-null type java.lang.String");
}
throw new TypeCastException("null cannot be cast to non-null type java.lang.String");
}
- 那这里的主要内容就是这个g方法了,同样进去看看;
public static String g(byte[] bArr, byte[] bArr2) {
try {
MessageDigest messageDigest = MessageDigest.getInstance(AaidIdConstant.SIGNATURE_SHA256);
messageDigest.reset();
messageDigest.update(bArr);
if (bArr2 != null) {
messageDigest.update(bArr2);
}
return g.H(messageDigest.digest());
} catch (NoSuchAlgorithmException e) {
throw new AssertionError(e);
}
}
- 打眼一看应该就是一个sha256算法;
- 那么接下来就是需要hook验证了,先去hook H7看看位置对不对;
function hook_h7() {
Java.perform(function () {
var ByteString = Java.use("com.android.okhttp.okio.ByteString");
let d = Java.use("tv.danmaku.biliplayerimpl.report.heartbeat.d");
d["H7"].implementation = function (j2, j4, i, j5, j6, i2, i3, j7, str, i4, str2, str3) {
console.log(`d.H7 is called: j2=${j2}, j4=${j4}, i=${i}, j5=${j5}, j6=${j6}, i2=${i2}, i3=${i3}, j7=${j7}, str=${str}, i4=${i4}, str2=${str2}, str3=${str3}`);
let result = this["H7"](j2, j4, i, j5, j6, i2, i3, j7, str, i4, str2, str3);
console.log(`d.H7 result=${ByteString.of(result).hex()}`);
return result;
};
})
}
hook_h7()
- hook的结果如下,转成16进制输出;
[Pixel 4::哔哩哔哩 ]-> d.H7 is called: j2=1205801029, j4=1593015651, i=1, j5=1734867854, j6=1734855270, i2=3, i3=0, j7=0, str=, i4=0, str2=main.ugc-video-detail.0.0, str3=main.ugc-video-detail.0.0
d.H7 result=36c64b4e8b138184f3c14a734642b5a2168e1cd05181eed73655abdddca85f7d8d7379750746b64e55e8285d4ba87747f78439f6c9f629cc9fcd35edb2748d2ef7ad4e4221bb01363c86624dc7fc8256627fe6315ea001c3dcc701c6facd7dd46621225582b367c2ec7844035a20be2c9218c58c39d6641b90a7d2fb91e6dad2a1124c848d27cfcab1541714f1f8390e80de2a4ab0ce13dfb4210a75df6792286fe7029b7d11d1362add404442bedeee3e6911f36fa360a2d1cad53be0fce4aada0eb373f5f065a36e35db91242923ac23a3eac4b5c5d08f79298a840249a674b746af2273f97a3d3d07522061eb3e324824fe9701cf583e85c73ab4997ed06bcce957773dd909af1531e2bf2a477b4f2f7901137c7d96e82835d3a96b79952428a5f4474ae1b54ce901c9649eb397f12523b9b536d099acaa62043db34f3d59
- 抓包的结果拿去对比一下;
- 可以发现位置是没有错的,那么就可以去看sign怎么来的了,上面知道了sign怎么来的了,那我们也直接去hook一下;
function hook_sign() {
Java.perform(function () {
let a = Java.use("com.bilibili.commons.m.a");
a["g"].implementation = function (bArr, bArr2) {
console.log(`a.g is called: bArr=${Bytes2utf8String(bArr)}, bArr2=${Bytes2utf8String(bArr2)}`);
let result = this["g"](bArr, bArr2);
console.log(`a.g result=${result}`);
return result;
};
})
}
hook_sign();
- 输出结果如下:
[Pixel 4::哔哩哔哩 ]-> a.g is called:
bArr=aid=1002480418&auto_play=0&build=6240300&cid=1487958098&did=bFpoC2lebA9uWm5ZJVkl&epid=&from_spmid=main.ugc-video-detail.0.0&ftime=1734855270&lv=0&mid=0&mobi_app=android&part=1&sid=0&spmid=main.ugc-video-detail.0.0&stime=1734869003&sub_type=0&type=3,
bArr2=9cafa6466a028bfb
a.g result=9d5a27d1f98c1a9ae66558db3c2a3804419f696511cc9c1b037a6c86965267f4
- 上面我们看源码觉得像sha256算法,这里它传了两个参数,本身是字节数组,我这里直接转成字符串,因为我们知道传进来的是什么,那么这个参数2在源码里是这样用的:
if (bArr2 != null) {
messageDigest.update(bArr2);
}
- 这毫无疑问就是盐嘛,也可以搜索一下看看;
- 这里还有key iv等关键词,那么这个可以留个心眼了,那就看看是不是标准算法;
- 没有问题,就是sha256加盐算法,是标准的,那这个java层的算法其实算法助手就解了;
- 剩下的请求体加密也就没啥可细说的了,加密是AES;
- 剩下的我就写了个主动调用,免得每次都去发请求;
function ByteString2Hex(data) {
var ByteString = Java.use("com.android.okhttp.okio.ByteString");
return ByteString.of(data).hex();
}
function hook_aes() {
Java.perform(function () {
let b = Java.use("t3.a.i.a.a.a.b");
b["a"].implementation = function (body) {
console.log(`hook_aes的参数--->>>${body}`);
let result = this["a"](body);
console.log(`hook_aes的结果--->>>${ByteString2Hex(result)}`);
return result;
};
})
}
hook_aes()
function call_H7() {
Java.perform(function () {
var ByteString = Java.use("com.android.okhttp.okio.ByteString");
let dClass = Java.use("tv.danmaku.biliplayerimpl.report.heartbeat.d");
let dInstance = dClass.$new();
let j2 = 1205801029; // long类型的值
let j4 = 1593015651; // long类型的值
let i = 1; // int类型的值
let j5 = 1734867854; // long类型的值
let j6 = 1734855270; // long类型的值
let i2 = 3; // int类型的值
let i3 = 0; // int类型的值
let j7 = 0; // long类型的值
let str = ""; // String类型的值
let i4 = 0; // int类型的值
let str2 = "main.ugc-video-detail.0.0"; // String类型的值
let str3 = "main.ugc-video-detail.0.0"; // String类型的值
// 调用H7方法
let result = dInstance.H7(j2, j4, i, j5, j6, i2, i3, j7, str, i4, str2, str3);
result = ByteString.of(result).hex()
console.log('主动调用--->>>' + result);
})
}
// callH7();
- 直接拿固定的测试一下,主动调用一下看看结果;
[Pixel 4::哔哩哔哩 ]-> call_H7()
hook_aes的参数--->>>aid=1205801029&auto_play=0&build=6240300&cid=1593015651&did=bFpoC2lebA9uWm5ZJVkl&epid=&from_spmid=main.ugc-video-detail.0.0&ftime=1734855270&lv=0&mid=0&mobi_app=android&part=1&sid=0&spmid=main.ugc-video-detail.0.time=1734867854&sub_type=0&type=3&sign=8e5f7097273861c2141cff4e1da69fb05e876401c0884c718cf2e68a599676b5
hook_aes的结果--->>>36c64b4e8b138184f3c14a734642b5a2168e1cd05181eed73655abdddca85f7d8d7379750746b64e55e8285d4ba87747f78439f6c9f629cc9fcd35edb2748d2ef7ad4e4221bb01363c86624dc7fc8256627fe6315ea001c3dcc701c6facd7dd46621225582b367c2ec7835a20be2c9218c58c39d6641b90a7d2fb91e6dad2a1124c848d27cfcab1541714f1f8390e80de2a4ab0ce13dfb4210a75df6792286fe7029b7d11d1362add404442bedeee3e6911f36fa360a2d1cad53be0fce4aada0eb373f5f065a36e35db91242923ac23a3eac4b5c5d08f79298a840249a674b746af2273f97a3d3d07522061eb3e324824fe9701cf583e85c73ab4997ed06bcce957773dd909af1531e2bf2a477b4f2f7901137c7d96e82835d3a96b79952428a5f4474ae1b54ce901c9649eb397f12523b9b536d099acaa62043db34f3d59
主动调用--->>>36c64b4e8b138184f3c14a734642b5a2168e1cd05181eed73655abdddca85f7d8d7379750746b64e55e8285d4ba87747f78439f6c9f629cc9fcd35edb2748d2ef7ad4e4221bb01363c86624dc7fc8256627fe6315ea001c3dcc701c6facd7dd46621225582b367c2ec7844035a2c9218c58c39d6641b90a7d2fb91e6dad2a1124c848d27cfcab1541714f1f8390e80de2a4ab0ce13dfb4210a75df6792286fe7029b7d11d1362add404442bedeee3e6911f36fa360a2d1cad53be0fce4aada0eb373f5f065a36e35db91242923ac23a3eac4b5c5d08f79298a840249a674b7746af2273f97a3d3d07522061eb3e324824fe9701cf583e85c73ab4997ed06bcce957773dd909af1531e2bf2a477b4f2f7901137c7d96e82835d3a96b79952428a5f4474ae1b54ce901c9649eb397f12523b9b536d099acaa6203db34f3d59
- 强烈建议把hook到的内容输出到文件里再测试,真的是害得我好苦。。。测试结果如下:
- 是对的上的,那就证明没问题,只是提交的时候是原格式而不是hex;
- 那么明文参数也需要看一看,aid和cid其实是视频相关的id,ftime是视频打开时间,stime是视频开始播放时间,剩下的就是一个did了;
- 经过多次清除数据尝试,最终定位到其实就是在取mac地址;
- 后续做加密的时候还需要拼接三个 | 符号,这是在我的设备上的方式;
- 就类似这里,其实最理想的获取到应该为这样的组合:
mac地址|蓝牙地址|设备总线|sn号
// 去跟一下java层的逻辑就知道了
- 但在我的设备上hook多次都只是得到如下结果:c62cb72ca447|||,那么自行模拟即可;
- b方法就是加密的算法,明文就是上面说的东西,使用python复现b方法即可;
import base64
def b(str):
# 将字符串转换为字节
bytes_str = bytearray(str.encode())
# 进行位操作
bytes_str[0] = bytes_str[0] ^ (len(bytes_str) & 255)
for i in range(1, len(bytes_str)):
bytes_str[i] = (bytes_str[i - 1] ^ bytes_str[i]) & 255
try:
return base64.b64encode(bytes(bytes_str)).decode()
except Exception:
return str
# 使用示例
encoded_str = b("c62cb72ca447|||")
print(encoded_str)
# bFpoC2lebA9uWm5ZJVkl
- 这里生成的did也是和参数里的对的上的,那么模拟就很容易了;
- 那么请求体的分析就完成了;
3.2 请求头分析
- 具体参数如下:
请求头:
buvid XY88A4185B06248EA82BB0101C2ADC5DAD33B
device-id bFpoC2lebA9uWm5ZJVkl
fp_local d9c996c3fb98c1808bbf64ee054dda5420241222161236dee9d93eca085cd19e
fp_remote d9c996c3fb98c1808bbf64ee054dda542024112221174303fb9f17f667dc29f3
session_id f815bbd9
Ⅰbuvid
- 先看buvid的生成,我这里直接hook了hashmap的put方法,可以定位到put的位置如下:
- 往下稍微找一下即可发现关键的位置;
- 定位的方法很多,能定位到都行,不需要拘泥于定位代码的方式,重点并不在此;
- 然后就可以去跟了,最终大概能跟到这个位置;
- 可以去hook一下,发现他确实是走了这里,但在hook之前需要先清除数据,并且它的结果是以XY开头的,那么就很显然是走的哪一个位置了;
- j2其实是mac地址,上面的分析也是走到了那个位置,java层都是很简单的,去跟一下就很明了了,a.d方法是一个md5,e方法需要python复现;
def md5(input_string):
md5_obj = hashlib.md5()
md5_obj.update(input_string.encode('utf-8'))
md5_hash = md5_obj.hexdigest()
return md5_hash
def e(str_input):
sb = []
sb.append(str_input[2]) # Python索引从0开始,所以这里是第3个字符
sb.append(str_input[12]) # 第13个字符
sb.append(str_input[22]) # 第23个字符
return ''.join(sb)
def main():
mac_id = "C6:2C:B7:2C:A4:47"
data = md5(mac_id)
res = "XY" + e(data) + data
print(res)
if __name__ == '__main__':
main()
# hook到:XY88a4185b06248ea82bb0101c2adc5dad33b
# 运行结果:XY88a4185b06248ea82bb0101c2adc5dad33b
- 这里的mac地址是没有去掉中间的分割符的,需要注意一下;
Ⅱ sessionid
- 接下来看sessionid的生成,直接去看代码;
- 随便跟两步就能跟到一个接口,众所周知接口那就找实现;
- 可以两个都去看一下,真实的就是下面这个,进去后找它实现的getSessionId方法,再次进入;
- 又是一个接口,同样的操作继续去看;
- 最终就能跟到这里,读一下逻辑可以发现他去读了,如果没有就返回L,那我们理想的肯定是L的生成才是最好的,那就去找L的生成位置;
- 高亮选中即可找到,其实就是在随机,直接给出代码;
def generate_random_hex():
bArr = [random.randint(0, 255) for _ in range(4)]
hex_str = binascii.hexlify(bytearray(bArr)).decode('utf-8')
return hex_str
Ⅲ fp_local
- 根据上述的逻辑可以跟到这个位置;
- 还需要继续往下跟,但这里的方法告诉我们它可能是一个指纹信息;
public final String c() {
String str = "";
if (k()) {
ReentrantReadWriteLock.ReadLock r = e;
x.h(r, "r");
r.lock();
try {
if (a != null && (str = a) == null) {
x.Q("buvidLocal");
}
} finally {
r.unlock();
}
}
return str;
}
- 跟进来之后,str = a就是它赋值的位置,那么这里就需要找a的赋值位置,可以直接搜a =来定位;
- 这个d2可能就是我们的fp_local的值,那么我们hook验证一下;
function hook_local() {
Java.perform(function () {
let a = Java.use("com.bilibili.lib.biliid.internal.fingerprint.a.a");
a["a"].implementation = function (buvidLegacy, data) {
console.log(`a.a is called: buvidLegacy=${buvidLegacy}, data=${data}`);
let result = this["a"](buvidLegacy, data);
console.log(`a.a result=${result}`);
return result;
};
})
}
hook_local()
- 这里清理内存再hook,可以发现确实是走了这里,位置是没错的;
a.a is called: buvidLegacy=XY88A4185B06248EA82BB0101C2ADC5DAD33B, data=Data(main={str_brightness=31, app_version=6.24.0, cpuModel=ARMv8 Processor rev 14 (v8l), speed_sensor=1, adb_enabled=1, screen=1080,2236,440, ui_version=qd1a.190821.011, linear_speed_sensor=1, virtualproc=[], sensors_info=[{"name":"LSM6DSR
·······
Accelerometer","vendor":"STMicro","version":"142083","type":"1","maxRange":"156.9064","resolution":"0.0047856453","power":"0.17","minDelay":"2404"}, {"name":"LIS2MDL Magnetometer","vendor":"STMicro","version":"262","type":"2","maxRange":"4915.2","resolution":"0.1","power["1230768000000,com.google.omadm.trigger,1,1.0,1,1230768000000","1230768000000,com.google.android.carriersetup,1,10,29,1230768000000","1230768000000,com.android.cts.priv.ctsshim,1,9-5374186,28,1230768000000","1230768000000,com.google.android.youtube,1,14.19.57,1419573400,1230768000000",t.sys.country=, ro.boot.serialno=, gsm.network.type=Unknown, net.eth0.gw=, net.dns1=, sys.usb.state=, http.agent=}, sys={product=flame, cpu_abi=armeabi-v7a, serial=unknown, display=QD1A.190821.011, fingerprint=google/flame/flame:10/QD1A.190821.011/5849216:user/release-keys, cpu_abi2=armeabi, device=flame, manufacturer=Google, hardware=flame})
a.a result=d9c996c3fb98c1808bbf64ee054dda5420241224203228adabc704bcb23630d6
- 第一个参数是我们的buvidLegacy,其实就是buvid,第二个就是环境相关的;
- 可以看到确实是很多环境信息,包括了很多东西,甚至还读了我们安装的所有apk的信息,此时抓包的fp_local也确实是我们hook到的,那生成位置也就确定了;
- 继续跟到详细的位置;
public static final String a(String buvidLegacy, com.bilibili.lib.biliid.internal.fingerprint.b.a data) {
String str;
// 这两句是log
// x.q(buvidLegacy, "buvidLegacy");
// x.q(data, "data");
return (MiscHelperKt.a(f(buvidLegacy, data)) + h() + MiscHelperKt.a(g())) + b(str);
}
- 拆解一下,MiscHelperKt.a(f(buvidLegacy, data)) + h() + MiscHelperKt.a(g()),它的结果 + b(str)就是最终的fp_local;
- 一个一个来看,首先看 g() :
- 那么 g() 就是在随机8位,因为e.a方法是随机,且log也能看出;
- MiscHelperKt.a(g()):
- 这段代码的目的是将一个字节数组转换为它的十六进制字符串表示,也就是转16进制返回;
- h():
- 很明显时间戳相关的函数;
- f(buvidLegacy, data):
- f方法接收了两个参数,一个是buvid,一个是环境参数,它的返回值也需要拆解一下,但是也比较明了,buvid + 环境参数里的model + 环境参数里的band,后面这两个参数在刚刚hook到的内容也是可以找到的;
- 最后再通过e方法再返回,看看e方法;
- 是一个md5算法,就是上面提到过的md5;
- 那么整体的加密如下:
f(buvidLegacy, data):
md5(buvid + 环境参数里的model + 环境参数里的band)
- MiscHelperKt.a(f(buvidLegacy, data)) + h() + MiscHelperKt.a(g())):
- 看着也比较熟悉,应该也是将传入的内容转成16进制;
- b(str):
- 这个看着比较晦涩,中间的逻辑暂时不去管,最终返回的也是一个16进制的数据;
- 大致的逻辑看完了,整体理一下;
fp_local:
(MiscHelperKt.a(f(buvidLegacy, data)) + h() + MiscHelperKt.a(g())) + b(str);
[hex(md5(buvid + 环境参数里的model + 环境参数里的band)) + 时间戳 + hex(随机8位)] + hex_b(str)
- hex_b使用python复现即可,里面有三个数字的参与,需要确定下来;
- 剩余的内容就没有啥了,使用python复现即可,上面的b方法还缺了个参数,我hook之后发现其实就是前面的那一堆运算出去b方法的值,我没有找到具体位置,所以使用jeb反编译看看;
- 结合起来看是可以证明的,另一个参数fp_remote就无所谓了,其实是差不多的形式;
3.3 小结
- 请求体涉及sha256加盐、aes算法,请求头buvid是mac地址得到的,sessinid是随机的,fp_local涉及md5、时间戳等;
4. heartbeat接口
- 接下来分析心跳包接口,心跳包是什么意思?在app端,打开一个视频会发送一个心跳包,视频播放完毕,或者说你停止视频后也会发一个心跳包;
- 基础信息如下:
请求地址:
https://api.bilibili.com/x/report/heartbeat/mobile
请求方式:
POST
请求头:
buvid XY88A4185B06248EA82BB0101C2ADC5DAD33B
device-id bFpoC2lebA9uWm5ZJVkl
fp_local d9c996c3fb98c1808bbf64ee054dda542024122422130631012624a0802cf35b
fp_remote d9c996c3fb98c1808bbf64ee054dda542024112221174303fb9f17f667dc29f3
session_id 492359b7
- 接下来抓两次包进行对比,第一个是打开视频,第二个是视频播放结束;
- 视频start参数如下:
请求体:
actual_played_time 0
aid 113454211927230
appkey 1d8b6e7d45233436
auto_play 0
build 6240300
c_locale zh-Hans_CN
channel xxl_gdt_wm_253
cid 26690260642
epid 0
epid_status
from 2
from_spmid main.ugc-video-detail.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 80db3b980c3763fe5962aa35b2d42f380e7ead92
sid 0
spmid main.ugc-video-detail.0.0
start_ts 0
statistics {"appId":1,"platform":3,"version":"6.24.0","abtest":""}
sub_type 0
total_time 0
ts 1735130848
type 3
user_status 0
video_duration 34
sign 58a6bdd705a1b1934ca151d23ee27934
- 视频end参数如下:
请求体:
actual_played_time 35
aid 113454211927230
appkey 1d8b6e7d45233436
auto_play 0
build 6240300
c_locale zh-Hans_CN
channel xxl_gdt_wm_253
cid 26690260642
epid 0
epid_status
from 2
from_spmid main.ugc-video-detail.0.0
last_play_progress_time 34
list_play_time 0
max_play_progress_time 34
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 35
quality 32
s_locale zh-Hans_CN
session 80db3b980c3763fe5962aa35b2d42f380e7ead92
sid 0
spmid main.ugc-video-detail.0.0
start_ts 1735130823
statistics {"appId":1,"platform":3,"version":"6.24.0","abtest":""}
sub_type 0
total_time 35
ts 1735130883
type 3
user_status 0
video_duration 34
sign adddc27c9e78a2318576c08ea6d19a66
- 找个文本对比,看看哪些是不一样的;
actual_played_time 0
actual_played_time 35
真正播放视频的时间--》要等于视频总时间,才是完播
last_play_progress_time 0
last_play_progress_time 34
max_play_progress_time 0
max_play_progress_time 34
played_time 0
played_time 35
start_ts 0
start_ts 1735130823
total_time 0
total_time 35
ts 1735130848
ts 1735130883
sign 58a6bdd705a1b1934ca151d23ee27934
sign adddc27c9e78a2318576c08ea6d19a66
- 大致有区别的参数就是这些,看着需要逆的就是sign加上session,虽然session并没有变化,但是不同的心跳是不一样的;
- 请求头的参数其实在第一个接口就已经全部完成了,那么大概就需要sign+session;
4.1 session
- 首先分析session的生成,这里我选择hook hashmap的put方法定位,代码如下:
function showStacks() {
console.log(
Java.use("android.util.Log")
.getStackTraceString(
Java.use("java.lang.Throwable").$new()
)
);
}
Java.perform(function () {
var hashMap = Java.use("java.util.HashMap");
hashMap.put.implementation = function (a, b) {
// 键名
if (a.equals("session")) {
showStacks();
console.log("hashMap.put: ", a, b);
}
return this.put(a, b);
}
})
- 打印的输出如下:
java.lang.Throwable
at java.util.HashMap.put(Native Method)
at com.bilibili.api.base.util.ParamsMap.putParams(BL:5)
at tv.danmaku.biliplayerimpl.report.heartbeat.HeartbeatParams.<init>(BL:3)
at tv.danmaku.biliplayerimpl.report.heartbeat.d.N7(BL:30)
at tv.danmaku.biliplayerimpl.report.heartbeat.d.P7(BL:2)
at tv.danmaku.biliplayerimpl.report.heartbeat.d.L7(BL:3)
at tv.danmaku.biliplayerimpl.report.heartbeat.d.u7(BL:3)
at tv.danmaku.biliplayerimpl.core.PlayerCoreServiceV2$l.onPrepared(BL:2)
at t3.a.i.b.i$j.onPrepared(BL:6)
at tv.danmaku.ijk.media.player.AbstractMediaPlayer.notifyOnPrepared(BL:2)
at tv.danmaku.ijk.media.player.IjkMediaPlayer$EventHandler.handleMessage(BL:107)
at android.os.Handler.dispatchMessage(Handler.java:107)
at android.os.Looper.loop(Looper.java:214)
at android.app.ActivityThread.main(ActivityThread.java:7356)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:492)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:930)
hashMap.put: session bacd228eef6eb4c3f57621de97e557e9d895f372
- 这里就可以很显然的看到很多关键的信息,就根据逻辑去找;
- 看谁调用了它,根据堆栈去找就行,再往下跟一下,这里r1是调用的方法,那么找this.d的赋值即可;
- 再去找t2是谁调用了;
public final String a() {
Random random = new Random();
StringBuilder sb = new StringBuilder();
c2.f.b0.c.b.b.a.a E = c2.f.b0.c.b.b.a.a.E();
x.h(E, "EnvironmentPrefHelper.getInstance()");
sb.append(E.t()); // 获取buvid
sb.append(System.currentTimeMillis()); // 时间戳
sb.append(random.nextInt(1000000)); // 随机数
String sb2 = sb.toString();
String sha1 = com.bilibili.commons.m.a.i(sb2); // sha1加密
if (TextUtils.isEmpty(sha1)) {
return sb2;
}
x.h(sha1, "sha1");
return sha1;
}
- 这里大概就已经定位到了,有sha1关键词,具体的参数是什么我也在上方解释了;
- 这里hook一下这个i方法也就是sha1方法看看参数是不是正确;
a.i is called: str=1735132995295639864
a.i result=26148cf9d1cdc70ea154d68ddb325e25bac5e2fd
- 这里的参数可以发现貌似是没有buvid的参与的,只有后两个,加密算法是标准的;
- 明文就是时间戳+随机数,加密方式为sha1算法;
4.2 sign
- 大致一看它是32位的,可能是一个md5,但也不能保证,只是猜测,心里留心眼即可;
- 在上面那个居多参数的位置,并没有看到sign参数在,那么这个怎么定位?我是选择hook NewStringUTF方法,这是与native交互的,这里判断依据就是sign的长度,它是固定32的,hook脚本如下:
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) {
Interceptor.attach(addrNewStringUTF, {
onEnter: function (args) {
var c_string = args[1];
var dataString = c_string.readCString();
// 你的参数的特征
if (dataString && dataString.length == 32) {
console.log(dataString);
console.log(Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n') + '\n');
console.log(Java.use("android.util.Log").getStackTraceString(Java.use("java.lang.Throwable").$new()));
}
}
});
}
- hook的结果如下:
e4773e52b9f23f3c788191ce92763cfa
0xbc7061a5 libbili.so!0x31a5
java.lang.Throwable
at com.bilibili.nativelibrary.LibBili.s(Native Method)
at com.bilibili.nativelibrary.LibBili.g(BL:1)
at com.bilibili.okretro.f.a.h(BL:1)
at com.bilibili.okretro.f.a.c(BL:14)
at com.bilibili.okretro.f.a.a(BL:6)
at com.bilibili.okretro.d.a.execute(BL:24)
at com.bilibili.okretro.d.a$a.run(BL:2)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
at java.lang.Thread.run(Thread.java:919)
0xbc7061a5 libbili.so!0x31a5
- 根据堆栈去找,就可以找到生成它的native方法,so文件是libbili.so;
- 去hook一下看看入参,hook脚本如下:
function hook_heart() {
Java.perform(function () {
let LibBili = Java.use("com.bilibili.nativelibrary.LibBili");
var TreeMap = Java.use("java.util.TreeMap");
LibBili["s"].implementation = function (map) {
var obj = Java.cast(map, TreeMap);
console.log("map=", obj.toString());
// console.log(`LibBili.s is called: sortedMap=${sortedMap}`);
let result = this["s"](map);
console.log(`LibBili.s result=${result}`);
return result;
};
})
}
- 结果如下:
入参:
{actual_played_time=0,
aid=887018547,
appkey=1d8b6e7d45233436,
auto_play=0,
build=6240300,
c_locale=zh-Hans_CN,
channel=xxl_gdt_wm_253,
cid=309627230,
epid=0,
epid_status=,
from=2,
from_spmid=main.ugc-video-detail.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=109275c491a0bdaa56ebda098ca39202f733dc53,
sid=0,
spmid=main.ugc-video-detail.0.0,
start_ts=0,
statistics={"appId":1,"platform":3,"version":"6.24.0","abtest":""},
sub_type=0,
total_time=0,
type=3,
user_status=0,
video_duration=111}
返回值:
63cb021860cbb7c5fa9c6c838e9b3937 这里的返回值有拼接入参
- 接下来就可以去看so了,它不是静态注册,所以我们去看jnionload;
- 这里会有一些问题,很明显看到我们c里面的函数并不是sub开头,而且跳转过去也没法反编译,这里应该是识别的问题,这里先跳过去,然后按下P键强制识别为函数,就可以反编译了;
- 分析一下伪代码;
int __fastcall sub_1C96(JNIEnv *a1, int a2, int a3)
{
const char *v5; // r1
const char *v6; // r2
int v8; // r0
JNIEnv v9; // r1
void *v10; // r0
void *v11; // r8
int v12; // r9
jobject v13; // r4
jstring v14; // r5
const char *v15; // r0
int v16; // r5
_DWORD *v17; // r9
int v18; // r10
_DWORD *v19; // r0
int v20; // r1
int v21; // r2
int v22; // r0
size_t v23; // r5
int i; // r4
char *v25; // r5
int j; // r4
jobject v27; // [sp+8h] [bp-D0h]
char *v28; // [sp+Ch] [bp-CCh]
char *v29; // [sp+10h] [bp-C8h]
int v30; // [sp+14h] [bp-C4h]
char s[40]; // [sp+18h] [bp-C0h] BYREF
char v32[88]; // [sp+40h] [bp-98h] BYREF
char v33[24]; // [sp+98h] [bp-40h] BYREF
if ( sub_2E34(a1) )
{
v5 = "java/lang/ClassNotFoundException";
v6 = "com.bilibili.nativelibrary.SignedQuery";
LABEL_3:
sub_4414(a1, v5, v6);
return 0;
}
if ( !a3 )
{
v5 = "java/lang/NullPointerException";
v6 = "Null params!";
goto LABEL_3;
}
v8 = sub_6680(a1, a3);
v9 = *a1;
if ( v8 )
return v9->NewObject(a1, dword_B0FC, dword_B100, 0, 0);
v30 = v9->NewStringUTF(a1, "appkey");
v10 = sub_64C4(a1, a3);
v11 = v10;
if ( v10 )
{
v29 = (*a1)->GetStringUTFChars(a1, v10, 0);
v12 = sub_34B8(v29);
}
else
{
v12 = -1;
v29 = 0;
}
sub_3414(a1, a3);
v13 = (*a1)->CallStaticObjectMethod(a1, dword_B0FC, dword_B104, a3);
v14 = 0;
if ( sub_4368(a1) )
v13 = 0;
v15 = (*a1)->GetStringUTFChars(a1, v13, 0);
if ( v12 != -1 )
{
v28 = v15;
v16 = v12;
(*a1)->ReleaseStringUTFChars(a1, v11, v29);
v17 = malloc(0x10u);
if ( v17 )
{
v27 = v13;
v18 = dword_978C[v16];
v19 = &dword_978C[v16];
v20 = v19[7];
v21 = v19[14];
v22 = v19[21];
*v17 = v18;
v17[1] = v20;
v17[2] = v21;
v17[3] = v22;
memset(s, 0, 0x21u); // 初始化内存区域
v23 = strlen(v28);
memset(v33, 0, sizeof(v33));
memset(v32, 0, sizeof(v32));
sub_227C(v32);
sub_22B0(v32, v28, v23);
sprintf(v33, "%08x", v18);
sub_22B0(v32, v33, 8);
for ( i = 1; i != 4; ++i )
{
sprintf(v33, "%08x", v17[i]);
sub_22B0(v32, v33, 8);
}
sub_2AE0(v33, v32);
v25 = s;
for ( j = 0; j != 16; ++j )
{
sprintf(v25, "%02x", v33[j]);
v25 += 2;
}
free(v17);
v13 = v27;
v14 = (*a1)->NewStringUTF(a1, s);
}
else
{
v14 = 0;
}
}
sub_45C8(a1, v30);
return (*a1)->NewObject(a1, dword_B0FC, dword_B100, v13, v14); // v14 sign
}
- 这里先看这一小段:
- 依靠你灵敏的嗅觉,这里传了一个长度,这是一个很重要的信号,传长度又传了本身,那它就极有可能是在做加密,那么这个v28就应该是明文,我们去看看是不是;
- a3就是传进来的第三个参数,而a2没有使用到,那么a3就肯定是我们的明文,那么现在的目的肯定就是看sub_22B0这个方法;
- 在这里结合一下之前的想法,他可能是一个md5,这是我们猜想的,那么来看一下md5的参与到加密的函数;
- MD5Update很符合我们这个sub_22B0吧,进去看看;
- 这里我根据算法修改了一下名字,看起来会舒适一点,这里没有看到熟悉的64轮和k表,但有一个sub_2334函数,点进去看一眼;
- 这个不用多说了吧,很明显的特征了,那么再回到最开始的位置,sub_227C就一定是init了吧,进去也一定能看到更加熟悉的东西;
- 这几个数字是md5最显著的特征,这里我右键转成了16进制,更加明显;
- 这里是可以魔改的,这也是md5魔改的最多的地方,其次就是k表里面,不熟悉的可以google一下这几个数字,也是会找到答案的;
- 那么我们静态分析大概就是这些,接下来去hook看看情况;
function hook_so() {
Java.perform(function () {
var libbili = Module.findBaseAddress("libbili.so");
var s_func = libbili.add(0x22b0 + 1);
console.log(s_func);
Interceptor.attach(s_func, {
onEnter: function (args) {
console.log("执行update长度是:", args[2].toInt32());
console.log(args[1].readUtf8String())
},
onLeave: function (args) {
console.log("=======================结束===================");
}
});
});
}
hook_so()
- hook的结果如下:
执行update长度是: 742
actual_played_time=1&aid=113561099573476&appkey=1d8b6e7d45233436&auto_play=0&build=6240300&c_locale=zh-Hans_CN&channel=xxl_gdt_wm_253&cid=27080001673&epid=0&epid_status=&from=2&from_spmid=main.ugc-video-detail.0.0&last_play_progress_time=1&list_play_time=0&max_play_progress_time=1&mid=0&miniplayer_play_time=0&mobi_app=android&network_type=1&paused_time=609&platform=android&play_status=0&play_type=1&played_time=1&quality=32&s_locale=zh-Hans_CN&session=e70b3e4d0b56bda8b4dc3875d760b4ad8ba1e6af&sid=0&spmid=main.ugc-video-detail.0.0&start_ts=1735219209&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=610&ts=1735219844&type=3&user_status=0&video_duration=321
=======================结束===================
执行update长度是: 8
560c52cc
=======================结束===================
执行update长度是: 8
d288fed0
=======================结束===================
执行update长度是: 8
45859ed1
=======================结束===================
执行update长度是: 8
8bffd973
=======================结束===================
- 在明文填充之后,又增加了四次update,这里熟悉的肯定就知道了,这是加盐的过程,我们去测试一下看是不是标准的md5;
- 运气比较好,它是标准的,如果魔改了还需要去进一步的分析,那么这里的sign也就分析完成了;
5. 总结
- 总体来说难度很低,但我非常建议新手朋友从头到尾来一遍,不管是hook还是主动调用,又或者是跟java层的动向,这篇解决了,那java层就没什么难度了;