安卓逆向-Bilibili播放量接口

sana
1月6日发布 /正在检测是否收录...

一、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
  • 抓包示例如下:

image-20241222162234422

  • 第一个是心跳包,熟悉的朋友可能知道,控制完播率这方面的,剩下一个就是增加播放量的;

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
请求体:
        ùo”u‰OŸ˜\#âœèhA^‘>o(](–©ú······’Ã<xxÚý\@aèšyd
        是一串乱码样式的内容

3.1 请求体分析

  • 直接反编译搜索地址的后部分即可,这样比较唯一,搜索到后直接点过去;

image-20241222163602968

  • 这里可以发现心跳接口其实也在这里,并且它是一个接口,那就去找它的实现,右键查找用例或者按x,也只有一个位置,点进去看看;

image-20241222164440857

  • 这里其实也跟明了,看不懂的可以去了解一下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
  • 抓包的结果拿去对比一下;

image-20241222194700257

  • 可以发现位置是没有错的,那么就可以去看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);
}
  • 这毫无疑问就是盐嘛,也可以搜索一下看看;

image-20241222200924850

  • 这里还有key iv等关键词,那么这个可以留个心眼了,那就看看是不是标准算法;

image-20241222201004840

  • 没有问题,就是sha256加盐算法,是标准的,那这个java层的算法其实算法助手就解了;
  • 剩下的请求体加密也就没啥可细说的了,加密是AES;

image-20241222201646511

  • 剩下的我就写了个主动调用,免得每次都去发请求;
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到的内容输出到文件里再测试,真的是害得我好苦。。。测试结果如下:

image-20241222215620570

  • 是对的上的,那就证明没问题,只是提交的时候是原格式而不是hex;
  • 那么明文参数也需要看一看,aid和cid其实是视频相关的id,ftime是视频打开时间,stime是视频开始播放时间,剩下的就是一个did了;
  • 经过多次清除数据尝试,最终定位到其实就是在取mac地址;

image-20241222224239486

  • 后续做加密的时候还需要拼接三个 | 符号,这是在我的设备上的方式;

image-20241222224410823

  • 就类似这里,其实最理想的获取到应该为这样的组合:
mac地址|蓝牙地址|设备总线|sn号
// 去跟一下java层的逻辑就知道了
  • 但在我的设备上hook多次都只是得到如下结果:c62cb72ca447|||,那么自行模拟即可;

image-20241222225011024

  • 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的位置如下:

image-20241223205849138

  • 往下稍微找一下即可发现关键的位置;

image-20241223210846153

  • 定位的方法很多,能定位到都行,不需要拘泥于定位代码的方式,重点并不在此;
  • 然后就可以去跟了,最终大概能跟到这个位置;

image-20241223213947561

  • 可以去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的生成,直接去看代码;

image-20241223223340300

  • 随便跟两步就能跟到一个接口,众所周知接口那就找实现;

image-20241223223508895

  • 可以两个都去看一下,真实的就是下面这个,进去后找它实现的getSessionId方法,再次进入;

image-20241223223545762

  • 又是一个接口,同样的操作继续去看;

image-20241223223618042

  • 最终就能跟到这里,读一下逻辑可以发现他去读了,如果没有就返回L,那我们理想的肯定是L的生成才是最好的,那就去找L的生成位置;

image-20241223223719413

  • 高亮选中即可找到,其实就是在随机,直接给出代码;
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
  • 根据上述的逻辑可以跟到这个位置;

image-20241224203428433

  • 还需要继续往下跟,但这里的方法告诉我们它可能是一个指纹信息;
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 =来定位;

image-20241224203605893

  • 这个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到的,那生成位置也就确定了;
  • 继续跟到详细的位置;

image-20241224204311025

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()

image-20241224204757228

  • 那么 g() 就是在随机8位,因为e.a方法是随机,且log也能看出;
  • MiscHelperKt.a(g())

image-20241224205101723

  • 这段代码的目的是将一个字节数组转换为它的十六进制字符串表示,也就是转16进制返回;
  • h()

image-20241224205230656

  • 很明显时间戳相关的函数;
  • f(buvidLegacy, data)

image-20241224205533510

  • f方法接收了两个参数,一个是buvid,一个是环境参数,它的返回值也需要拆解一下,但是也比较明了,buvid + 环境参数里的model + 环境参数里的band,后面这两个参数在刚刚hook到的内容也是可以找到的;
  • 最后再通过e方法再返回,看看e方法;

image-20241224205733591

  • 是一个md5算法,就是上面提到过的md5;
  • 那么整体的加密如下:
f(buvidLegacy, data):
    md5(buvid + 环境参数里的model + 环境参数里的band)
  • MiscHelperKt.a(f(buvidLegacy, data)) + h() + MiscHelperKt.a(g()))

image-20241224210220824

  • 看着也比较熟悉,应该也是将传入的内容转成16进制;
  • b(str)

image-20241224210609550

  • 这个看着比较晦涩,中间的逻辑暂时不去管,最终返回的也是一个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反编译看看;

image-20241224222959810

  • 结合起来看是可以证明的,另一个参数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
  • 这里就可以很显然的看到很多关键的信息,就根据逻辑去找;

image-20241225211523530

  • 看谁调用了它,根据堆栈去找就行,再往下跟一下,这里r1是调用的方法,那么找this.d的赋值即可;

image-20241225211630608

  • 再去找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;

image-20241225221249795

  • 去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;

image-20241226202848431

  • 这里会有一些问题,很明显看到我们c里面的函数并不是sub开头,而且跳转过去也没法反编译,这里应该是识别的问题,这里先跳过去,然后按下P键强制识别为函数,就可以反编译了;

image-20241226203342091

  • 分析一下伪代码;
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
}
  • 这里先看这一小段:

image-20241226204940315

  • 依靠你灵敏的嗅觉,这里传了一个长度,这是一个很重要的信号,传长度又传了本身,那它就极有可能是在做加密,那么这个v28就应该是明文,我们去看看是不是;

image-20241226205150214

  • a3就是传进来的第三个参数,而a2没有使用到,那么a3就肯定是我们的明文,那么现在的目的肯定就是看sub_22B0这个方法;
  • 在这里结合一下之前的想法,他可能是一个md5,这是我们猜想的,那么来看一下md5的参与到加密的函数;

image-20241226205642038

  • MD5Update很符合我们这个sub_22B0吧,进去看看;

image-20241226205745652

  • 这里我根据算法修改了一下名字,看起来会舒适一点,这里没有看到熟悉的64轮和k表,但有一个sub_2334函数,点进去看一眼;

image-20241226205847639

  • 这个不用多说了吧,很明显的特征了,那么再回到最开始的位置,sub_227C就一定是init了吧,进去也一定能看到更加熟悉的东西;

image-20241226210006876

  • 这几个数字是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;

image-20241226213702449

  • 运气比较好,它是标准的,如果魔改了还需要去进一步的分析,那么这里的sign也就分析完成了;

5. 总结

  • 总体来说难度很低,但我非常建议新手朋友从头到尾来一遍,不管是hook还是主动调用,又或者是跟java层的动向,这篇解决了,那java层就没什么难度了;
© 版权声明
THE END
喜欢就支持一下吧
点赞 0 分享 收藏
评论 抢沙发
OωO
取消