这篇文章上次修改于 1365 天前,可能其部分内容已经发生变化,如有疑问可询问作者。
title: 哔哩哔哩视频和字幕接口分析
date: 2020/10/04 17:00:13
updated: 2020/10/08 21:55:21
permalink: explore-bilibili-video-grpc-request/
toc: true
本文可能存在一些错误,欢迎指正
前言
在哔哩哔哩APP视频和字幕接口探索笔记这篇文章中,记录比较混乱,而且有些地方缺乏逻辑,看起来估计是一头雾水。
对此进行了梳理,力求展现一种科学、合理、高效的分析方式,避免后来者趟我走过的坑,并且补充了具体的脚本/代码。
这也算是入坑安卓安全/逆向的第一份作业,主要是练习逆向分析及相关工具的运用,感谢r0ysue大佬的课程和帮助。
另外希望能帮助阅读完这篇文章的人可以学到以下知识:
- 了解哔哩哔哩视频接口payload的构成
- 基于python进行protobuf编解码
- 使用objection分析函数调用
- gzip header的构成
效果预览:
环境和工具
分析时测试的视频是 我開動物園那些年 第二集
环境:
- bilibili v6.7.0
- 某米已root真机 Android 9
- Kali-Linux-2019.4-vmware-amd64
工具:
tool | version | comment |
---|---|---|
frida | 12.11.16 | |
frida-tools | 8.1.3 | |
frida-server | 12.11.16 | arm64 |
objection | 1.9.6 | |
wallbreaker | 最新版 | |
jadx-gui | 1.1.0 |
pip install frida==12.11.16
pip install frida-tools==8.1.3
pip install objection==1.9.6
2020/11/10补充 js所用runtime是v8
其他补充:
function printfields(obj, fields){
for (var field in fields){
var key = fields[field];
var value = obj[key + "_"].value;
console.log(String(key).padEnd(15, " ") + ":" + value)
}
}
分析流程
APP接口数据抓包
具体抓包过程就不展开了,下面是抓包结果(涉及隐私部分使用*替换):
视频接口请求
https://grpc.biliapi.net/bilibili.pgc.gateway.player.v1.PlayURL/PlayView
user-agent: *
content-type: application/grpc
te: trailers
x-bili-fawkes-req-bin: CgdhbmRyb2lkEgRwcm9k
x-bili-metadata-bin: *
authorization: identify_v1 *
x-bili-device-bin: *
x-bili-network-bin: *
x-bili-restriction-bin:
x-bili-locale-bin: *
grpc-encoding: gzip
grpc-accept-encoding: identity,gzip
grpc-timeout: 17985446u
01 00 00 00 4D 1F 8B 08 00 00 00 00 00 00 00 E3
78 F2 5E 44 E0 CC BF 83 45 12 15 1A 02 0E 8C 5E
12 05 E9 C9 7A 40 AC 5B 96 99 92 9A AF 9B 92 5A
92 98 99 A3 67 A0 67 10 C4 9B 92 9A 96 58 9A 53
A2 5B 96 98 53 9A 9A C0 04 00 6D 12 E4 D4 3A 00
00 00
字幕接口请求
https://app.bilibili.com/bilibili.community.service.dm.v1.DM/DmView
env: prod
app-key: android
user-agent: *
x-bili-metadata-bin: *
authorization: identify_v1 *
x-bili-device-bin: *
x-bili-network-bin: *
x-bili-restriction-bin:
x-bili-locale-bin: *
x-bili-fawkes-req-bin: CgdhbmRyb2lkEgRwcm9k
content-type: application/grpc
content-length: 18
accept-encoding: gzip
cookie: bfe_id=*; sid=*
00 00 00 00 0D 08 FD 98 D6 D3 02 10 CC FE C1 72
18 01
请求定位
如何科学、准确定位请求从哪里产生呢?
答案是从开发者角度思考。
作为一个开发者,如果你要写一份请求api的代码,应该怎么做呢?
答案是选择一个http库,然后按文档给出的API编写代码。
了解这个逻辑后,就可以开始科学地分析了。
观察上述抓包结果,可以很发现一个明显的共同点,那就是请求头的content-type
中均为application/grpc
,那么可以猜测这个请求使用了grpc的库。
这个猜测成立的可能性有99%,剩下1%需要我们手动验证(说100%确定也不为过哈哈哈)。
将apk拖入jadx-gui中,搜索grpc
关键字,可以看到搜索结果非常多:
那么现在可以认为抓包的请求使用了grpc
,现在以此为突破口进行分析。
请求头定位
请求的payload通常会进行各种处理,好让你看不懂是什么东西,而请求头不会那么复杂,所以首先从请求头入手。
寻找(官方)例子辅助定位
查阅官方demo,找到一个HeaderClientInterceptor例子,它的作用是通过拦截器设定自定义请求头,抓包结果中请求头也是比较多的,推测大概率会采用类似的写法。
demo中HeaderClientInterceptor
通过implements ClientInterceptor
而来,在jadx-gui中搜索此关键词:
具体查看后,在b.edc
和b.edf
发现了抓包结果中的请求头:
解析kotlin的Metadata
@Metadata
看起来很奇怪,借助搜索引擎了解到反编译后出现这个是因为源代码是kotlin
写的,在导入的包中可以看到kotlin.Metadata
:
那么可以确定用到的grpc是kotlin版本的。
通过浅谈Kotlin语法篇之lambda编译成字节码过程完全解析(七)进一步了解,依葫芦画瓢,可以知道上面两个关键类的一些信息。
以b.edc
为例:
@Metadata(
bv = {1, 0, 3}, // 表示bytecode版本是 1.0.3
d1 = {"略"},
d2 = {
"Lcom/bilibili/lib/moss/internal/impl/grpc/interceptor/CommonInterceptor;",
"Lio/grpc/ClientInterceptor;", "()V",
"KEY_AUTH", "Lio/grpc/Metadata$Key;", "", "kotlin.jvm.PlatformType", "KEY_DEVICE", "", "KEY_LOCALE", "KEY_METADATA", "KEY_NETWORK", "KEY_RESTRICTION",
"addCommonHeader", "", "headers", "Lio/grpc/Metadata;",
"interceptCall", "Lio/grpc/ClientCall;",
"ReqT", "RespT", "method", "Lio/grpc/MethodDescriptor;",
"callOptions", "Lio/grpc/CallOptions;",
"next", "Lio/grpc/Channel;",
"moss_release"
},
k = 1, // 表示这是一个kotlin的类
mv = {1, 1, 15} // 表示metadata版本是 1.1.15
)
虽然结合上面的文章可以推测个大概,但是还是不够直观。
结合动态方法分析Metadata
因此结合官方文档和b.edc
实例信息进行推测。
大致推断d2表示的信息如下,类CommonInterceptor主要有:
- 实现ClientInterceptor(接口类)的interceptCall方法
- 一个addCommonHeader方法
- 六个静态域变量变量
而且通过b.edc
实例对比可以看到void a(io.grpc.ai);
是addCommonHeader
,io.grpc.f a(io.grpc.MethodDescriptor, io.grpc.d, io.grpc.e);
就是interceptCall
final xx Metadata$Key KEY_AUTH
final xx PlatformType KEY_DEVICE
final xx KEY_LOCALE, KEY_METADATA, KEY_NETWORK, KEY_RESTRICTION
addCommonHeader(Metadata headers)
interceptCall(MethodDescriptor<ReqT,RespT> method, CallOptions callOptions, Channel next)
一些对应关系:
名称 | (返回)类型 | 类型完整路径 |
---|---|---|
CommonInterceptor | 类 | com.bilibili.lib.moss.internal.impl.grpc.interceptor.CommonInterceptor |
ClientInterceptor | 类 | io.grpc.ClientInterceptor |
KEY_xxx | 静态域变量 | - |
addCommonHeader | void function | - |
headers | Metadata | io.grpc.Metadata |
interceptCall | ClientCall | io.grpc.ClientCall |
method | MethodDescriptor<ReqT,RespT> | io.grpc.MethodDescriptor |
callOptions | CallOptions | io.grpc.CallOptions |
next | Channel | io.grpc.Channel |
追踪addCommonHeader方法
(agent) [j7welji12m] Backtrace:
b.edc.a(Native Method)
b.edc.a(BL:38)
b.edc$a.a(BL:44)
io.grpc.t.a(BL:32)
b.edg$a.a(BL:28)
io.grpc.t.a(BL:32)
b.edj$a.a(BL:24)
io.grpc.t.a(BL:32)
b.edf$a.a(BL:34)
io.grpc.t.a(BL:32)
b.ede$a.a(BL:25)
io.grpc.stub.ClientCalls.a(BL:310)
io.grpc.stub.ClientCalls.a(BL:282)
io.grpc.stub.ClientCalls.a(BL:191)
io.grpc.stub.ClientCalls.a(BL:129)
com.bilibili.lib.moss.internal.impl.failover.a.a(BL:151)
com.bilibili.lib.moss.api.MossService.blockingUnaryCall(BL:41)
com.bapis.bilibili.pgc.gateway.player.v1.PlayURLMoss.playView(BL:59)
b.eby.a(BL:51)
b.ebo.a(BL:271)
b.ebo.resolveMediaResource(BL:170)
com.bilibili.lib.media.resolver.resolve.a.a(BL:143)
com.bilibili.lib.media.resolver.resolve.MediaResolveProvider.a(BL:151)
com.bilibili.lib.media.resolver.resolve.MediaResolveProvider.call(BL:59)
android.content.ContentProvider$Transport.call(ContentProvider.java:403)
android.content.ContentResolver.call(ContentResolver.java:1772)
com.bilibili.lib.media.resolver.resolve.MediaResolveProvider.a(BL:116)
com.bilibili.lib.media.resolver.resolve.a.a(BL:93)
b.eaz.intercept(BL:36)
b.eav.a(BL:32)
b.eav.call(BL:18)
java.util.concurrent.FutureTask.run(FutureTask.java:266)
b.eas$b.call(BL:136)
java.util.concurrent.FutureTask.run(FutureTask.java:266)
java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
java.lang.Thread.run(Thread.java:764)
(agent) [j7welji12m] Arguments b.edc.a("<instance: io.grpc.ai>")
(agent) [j7welji12m] Return Value: "(none)"
(agent) [4rhydvo6oqx] Backtrace:
io.grpc.ai.a(Native Method)
io.grpc.internal.a.a(BL:132)
io.grpc.internal.ae.a(BL:102)
io.grpc.internal.o.b(BL:279)
io.grpc.internal.o.a(BL:188)
io.grpc.internal.l$e$1.a(BL:394)
io.grpc.internal.k$c$1.a(BL:695)
io.grpc.t.a(BL:32)
b.edc$a.a(BL:45)
io.grpc.t.a(BL:32)
b.edg$a.a(BL:28)
io.grpc.t.a(BL:32)
b.edj$a.a(BL:24)
io.grpc.t.a(BL:32)
b.edf$a.a(BL:34)
io.grpc.t.a(BL:32)
b.ede$a.a(BL:25)
io.grpc.stub.ClientCalls.a(BL:310)
io.grpc.stub.ClientCalls.a(BL:282)
io.grpc.stub.ClientCalls.a(BL:191)
io.grpc.stub.ClientCalls.a(BL:129)
com.bilibili.lib.moss.internal.impl.failover.a.a(BL:151)
com.bilibili.lib.moss.api.MossService.blockingUnaryCall(BL:41)
com.bapis.bilibili.pgc.gateway.player.v1.PlayURLMoss.playView(BL:59)
b.eby.a(BL:51)
b.ebo.a(BL:271)
b.ebo.resolveMediaResource(BL:170)
com.bilibili.lib.media.resolver.resolve.a.a(BL:143)
com.bilibili.lib.media.resolver.resolve.MediaResolveProvider.a(BL:151)
com.bilibili.lib.media.resolver.resolve.MediaResolveProvider.call(BL:59)
android.content.ContentProvider$Transport.call(ContentProvider.java:403)
android.content.ContentResolver.call(ContentResolver.java:1772)
com.bilibili.lib.media.resolver.resolve.MediaResolveProvider.a(BL:116)
com.bilibili.lib.media.resolver.resolve.a.a(BL:93)
b.eaz.intercept(BL:36)
b.eav.a(BL:32)
b.eav.call(BL:18)
java.util.concurrent.FutureTask.run(FutureTask.java:266)
b.eas$b.call(BL:136)
java.util.concurrent.FutureTask.run(FutureTask.java:266)
java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
java.lang.Thread.run(Thread.java:764)
(agent) [4rhydvo6oqx] Arguments io.grpc.ai.a("<instance: io.grpc.ai$e, $className: io.grpc.ai$a>", "<instance: java.lang.Object, $className: java.lang.Long>")
(agent) [4rhydvo6oqx] Return Value: "(none)"
我们已经知道addCommonHeader实际对应的方法了,理论上推测经由这个函数会设置请求头。
查看反编译结果,基本可以确定void a(io.grpc.ai)
就是在设置请求头:
在aiVar.a
,也就是void a(io.grpc.ai$e, java.lang.Object)
进行hook,APP播放视频验证一下:
Java.perform(function () {
function bytesToString(value) {
var buffer = Java.array('byte', value);
var StringClass = Java.use('java.lang.String');
return StringClass.$new(buffer);
}
console.log("--->hook custom headers");
var ai = Java.use("io.grpc.ai");
ai.a.overload("io.grpc.ai$e", "java.lang.Object").implementation = function (key, value) {
console.log("=====>key:" + key._c.value)
if (value.getClass() == "class [B") {
var _value = bytesToString(value);
console.log("===>value:" + _value)
}
else {
console.log("===>value:" + value)
}
this.a(key, value);
}
});
下面是输出结果,key和抓包结果可以对上:
把抓包的请求头value进行base64解码操作,对比上面的输出,可以看到value不完全一样,应该还需要进行转换:
既然参数传到了io.grpc.ai
处理,那可以推测是在这个类中进行了base64编码,通过查看实例对象,可以看到确实有一个base64相关的变量:
其中omitPadding
)表示进行同样方法的编码,只是不填充。
既然在toString中使用了,可以观察一下什么时候会调用到它,不过可惜的是并没有进行调用。
那会不会和key有关呢?毕竟key的基类是io.grpc.ai$e
。
key参数类型是io.grpc.ai$e
,具体的实现类:
可以看到主要是
- io.grpc.ai$a
- io.grpc.ai$c
- io.grpc.ai$f
但可惜的是也没有发生调用。。。
不过既然出现了这个,那可不可以推测在其他地方使用了同样的方法呢?toString
在这里也许是开发者方便自己测试写的。一搜索果然其他位置果然有:
不过至少有3个位置,静态分析太慢,结合io.grpc.ai
的调用处sb.append(f8839c.a(b(i)));
来看,也就是com.google.common.io.BaseEncoding.base64().omitPadding()
,结合objection查看类实际情况,可以确定omitPadding
参数是单独的一个,那么hook命令如下:
android hooking watch class_method com.google.common.io.BaseEncoding.a [B --dump-args --dump-backtrace --dump-return
static final BaseEncoding f8839c = BaseEncoding.b().a(); // 静态方法b
StringBuilder sb = new StringBuilder("Metadata("); // sb是StringBuilder
sb.append(f8839c.a(b(i))); // 调用方法a,传入参数只有一个
果然出现了,而且有抓包结果中的值,那么根据下面的调用栈就可以分析具体的调用了。
(agent) [84yrtqomy77] Backtrace:
com.google.common.io.BaseEncoding.a(Native Method)
io.grpc.internal.ca.a(BL:64)
b.hxu.a(BL:342)
b.hxu.a(BL:62)
b.hxu$c.a(BL:162)
io.grpc.internal.a.a(BL:163)
io.grpc.internal.ae.a(BL:87)
io.grpc.internal.aq$b$1.a(BL:704)
io.grpc.internal.o.b(BL:287)
io.grpc.internal.o.a(BL:188)
io.grpc.internal.l$e$1.a(BL:394)
io.grpc.internal.k$c$1.a(BL:695)
io.grpc.t.a(BL:32)
b.edc$a.a(BL:45)
io.grpc.t.a(BL:32)
b.edg$a.a(BL:28)
io.grpc.t.a(BL:32)
b.edj$a.a(BL:24)
io.grpc.t.a(BL:32)
b.edf$a.a(BL:34)
io.grpc.t.a(BL:32)
b.ede$a.a(BL:25)
io.grpc.stub.ClientCalls.a(BL:310)
io.grpc.stub.ClientCalls.a(BL:282)
io.grpc.stub.ClientCalls.a(BL:191)
io.grpc.stub.ClientCalls.a(BL:129)
com.bilibili.lib.moss.internal.impl.failover.a.a(BL:151)
com.bilibili.lib.moss.api.MossService.blockingUnaryCall(BL:41)
com.bapis.bilibili.pgc.gateway.player.v1.PlayURLMoss.playView(BL:59)
b.eby.a(BL:51)
b.ebo.a(BL:271)
b.ebo.resolveMediaResource(BL:170)
com.bilibili.lib.media.resolver.resolve.a.a(BL:143)
com.bilibili.lib.media.resolver.resolve.MediaResolveProvider.a(BL:151)
com.bilibili.lib.media.resolver.resolve.MediaResolveProvider.call(BL:59)
android.content.ContentProvider$Transport.call(ContentProvider.java:403)
android.content.ContentResolver.call(ContentResolver.java:1772)
com.bilibili.lib.media.resolver.resolve.MediaResolveProvider.a(BL:116)
com.bilibili.lib.media.resolver.resolve.a.a(BL:93)
b.eaz.intercept(BL:36)
b.eav.a(BL:32)
b.eav.call(BL:18)
java.util.concurrent.FutureTask.run(FutureTask.java:266)
b.eas$b.call(BL:136)
java.util.concurrent.FutureTask.run(FutureTask.java:266)
java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
java.lang.Thread.run(Thread.java:764)
(agent) [84yrtqomy77] Arguments com.google.common.io.BaseEncoding.a([10,7,97,110,100,114,111,105,100,18,4,112,114,111,100])
(agent) [84yrtqomy77] Return Value: "CgdhbmRyb2lkEgRwcm9k"
梳理一下最后4步的调用(jadx没有把b.hxu.a(BL:62)
的调用表现出来,以及*.this.*
这种迷惑表示让人看不懂,后面还是借助了jeb,还是jeb强~):
builder.a(am.j.a(), this.d);
builder.a(am.h.a(), "application/grpc");
builder.a("te", "trailers");
这里是不是很眼熟,这就是前面差的那几个请求头了,至于this.d
,找个实例看一下,没错是user-agent
。
下一步的最终执行了base64编码操作,返回了一个二维byte数组,再往下看,可以很清楚看出来是在将编码后的结果进行byte到string的转换,而builder.a
传参和前面的user-agent
三个一致,可以确定就是在这里设定了全部的请求头。
当然还是查看一下传入的参数具体实现类,验证确定builder.a
就是在设置请求头:
然后发现这个是方法b
,眼花了,直接上objection看一下:
的确是BidirectionalStreamBuilderImpl.a
,但是通过extends
关键字并没有找到具体实现:
但可以肯定这里是在设置请求头。
等等,为什么不看一下强大的jeb呢!
不知道jadx不反编译出来的原因是什么 (关键词好像不对,没搜到,蹲一个解答) ,不过查看smali还是能看到的:
总之还是要优先动态分析,这样直观一点,定位起来更准确,不能过于依赖jadx的反编译结果,当然同时要查看smali代码也是能找到实际调用的。
还有一个te: trailers
请求头,网上能找到解释
另外user-agent
和content-type
一眼就能看出来构成,就不展开了。
那么现在可以做个小结:
x-bili-*-bin
相关的请求头在b.edc.a(io.grpc.ai)
生成x-bili-*-bin
相关的请求头在b.hxu.a(org.chromium.net.BidirectionalStream$Builder)
的ca.a进行了base64编码处理,然后设置- 其他请求头
user-agent、content-type、te
也在b.hxu.a(org.chromium.net.BidirectionalStream$Builder)
设置
前面已经知道请求头的生成位置,现在又有了编码前的位置,对两处进行hook,对比一下看看数据有没有差异。
对比无意义。
打印一下hook到的数据就行了,在io.grpc.ai.a("io.grpc.ai$e", "java.lang.Object")
下断点可以拿到请求头的key和未base64编码的value。这里的value不是原始数据,所以另外找一个点打印原始数据。
以x-bili-metadata-bin
为例,在类com.bapis.bilibili.metadata.Metadata
的writeTo
处hook,可以拿到原始数据。另外要进行base64编码的请求头value的共同特点:都经历了protobuf编码。
信息已经够多了,太多打印效果不好,知道原理就好,所以base64后的结果就不打印了。
请求头信息打印脚本代码(试图把多个写成一个循环,但发现并不能成,可能是this的原因),完整脚本见文末github地址:
Java.perform(function () {
console.log("--->hook request header information");
var ai = Java.use("io.grpc.ai");
ai.a.overload("io.grpc.ai$e", "java.lang.Object").implementation = function(key, value){
var header_key = key._c.value
console.log("----->"+header_key+"<-----");
if (value.$className == "[B"){
var buffer = Java.array('byte', value);
if (buffer.length > 0) dumpbytes(buffer);
}
else{
console.log(value)
}
console.log("-----><-----><-----");
this.a(key, value)
}
var meta = Java.use("com.bapis.bilibili.metadata.fawkes.FawkesReq");
meta.writeTo.overload("com.google.protobuf.CodedOutputStream").implementation = function (codedOutputStream) {
var header_key = "x-bili-fawkes-req-bin";
var fields = ["env", "appkey"];
console.log("----->"+header_key+"<-----");
printfields(this, fields);
console.log("-----><-----><-----");
this.writeTo(codedOutputStream);
}
});
payload定位
前面解决了请求头,现在再来看请求头两处切入点(生成和编码)的调用栈,以及先后执行关系:
即使这样最多也只能说明这里是仅仅在设置请求头,那发送数据呢?
还记得最开始的映射关系吗,a(io.grpc.MethodDescriptor, io.grpc.d, io.grpc.e);
就是interceptCall
,本质类型是io.grpc.ClientCall
,而在最前面的例子中super.onHeaders(headers);
后才是发送数据。
所以我们看下这个方法什么时候被调用了就能搞清楚更上一级的关系了:
(agent) [kyqu86860og] Backtrace:
b.edc.a(Native Method)
io.grpc.h$a.a(BL:156)
b.edg.a(BL:21)
io.grpc.h$a.a(BL:156)
b.edj.a(BL:20)
io.grpc.h$a.a(BL:156)
b.edf.a(BL:27)
io.grpc.h$a.a(BL:156)
b.ede.a(BL:20)
io.grpc.h$a.a(BL:156)
io.grpc.stub.ClientCalls.a(BL:127)
com.bilibili.lib.moss.internal.impl.failover.a.a(BL:151)
com.bilibili.lib.moss.api.MossService.blockingUnaryCall(BL:41)
com.bapis.bilibili.pgc.gateway.player.v1.PlayURLMoss.playView(BL:59)
b.eby.a(BL:51)
b.ebo.a(BL:271)
b.ebo.resolveMediaResource(BL:170)
com.bilibili.lib.media.resolver.resolve.a.a(BL:143)
com.bilibili.lib.media.resolver.resolve.MediaResolveProvider.a(BL:151)
com.bilibili.lib.media.resolver.resolve.MediaResolveProvider.call(BL:59)
android.content.ContentProvider$Transport.call(ContentProvider.java:403)
android.content.ContentResolver.call(ContentResolver.java:1772)
com.bilibili.lib.media.resolver.resolve.MediaResolveProvider.a(BL:116)
com.bilibili.lib.media.resolver.resolve.a.a(BL:93)
b.eaz.intercept(BL:36)
b.eav.a(BL:32)
b.eav.call(BL:18)
java.util.concurrent.FutureTask.run(FutureTask.java:266)
b.eas$b.call(BL:136)
java.util.concurrent.FutureTask.run(FutureTask.java:266)
java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
java.lang.Thread.run(Thread.java:764)
(agent) [kyqu86860og] Arguments b.edc.a("<instance: io.grpc.MethodDescriptor>", "<instance: io.grpc.d>", "<instance: io.grpc.e, $className: io.grpc.internal.ax>")
(agent) [kyqu86860og] Return Value: "<instance: io.grpc.f, $className: b.edc$a>"
如下图,这样一来就清晰了,req和res明显是代表请求和接收,所以请求是在com.bilibili.lib.moss.internal.impl.failover.a
中151行完成的,而 而传入的最后一个参数不出意外就是payload的了。io.grpc.stub.ClientCalls.a(BL:127)
里面则是发送处理,
io.grpc.stub.ClientCalls.a(BL:127)
这里不是处理请求头,因为有关于请求头处理的调用栈都是而
io.grpc.stub.ClientCalls.a(BL:129),那么正确的推测应该是:127处的调用相当于是初始化
ClientCall`,然后是发送数据,当然在发送前生成了请求头。
分析差不多了,那么可以正式转入payload的分析了,在合理的方法下 ,一切都是顺理成章的。
寻找原始数据
对传入请求的第四位,也就是请求数据有关的实例进行查看,这一下就能确认是原始数据了:
可能有人发现有点不对,最开始说epid的不是341978
吗,怎么是316825
了。啊这...341978是港澳地区的(实际上不需要代理也能看),但都是第二集。
所以数据认为是对上了~另外如果要抓341978
的数据,可以手机打开链接,然后跳转到APP,或者手机直接扫网页上提示的那个二维码就行,当然还有就是切换地区什么的。
寻找发送前数据
首先要有一个认识,那就是要知道grpc具体是干什么的,在此借用网上的一句话像调用本地对象一样直接调用另一台不同的机器上服务端应用的方法
,也就是说开发者专注于编写好消息定义文件(.proto),然后做一件类似于【初始化一个类,执行一个方法并得到结果】的事情,这部分开发者也许只需要写三行就能完成。
所以,所以不同于python中的requests
库,你发送数据会写requests.post(url, header=header, data=data)
,这时要发送的数据近在眼前,grpc真正发出请求的地方必然是在一个更为底层的某个地方。
为了得到更准确的从明文到乱码的payload的过程,就必须在离真正发送数据最近的地方进行hook验证。
回到官方文档,再看一次:
第一反应start
应该是开始请求,但是仔细一看就会发现发送数据是sendMessage
,而start
只是在接收消息。
再回到b.edc
这个类,Metadata看个大概,然后结合文档可以知道图中函数的对应关系:
梳理对应关系如下(最前面也有一个对应表,但那个是单个类的,这里更准确一点):
混淆后 | 混淆前 |
---|---|
b.edc | io.grpc.ClientInterceptor |
b.edc.a (methodDescriptor, dVar, eVar) | interceptCall(MethodDescriptor<ReqT,RespT> method, CallOptions callOptions, Channel next) |
b.edc.a (aiVar) | addCommonHeader(Metadata headers) |
b.edc$a | io.grpc.ForwardingClientCall$SimpleForwardingClientCall |
b.edc$a.a (reqt) | sendMessage(ReqT message) |
b.edc$a.a (aVar, aiVar) | start(ClientCall.Listener |
b.edc$a$a | io.grpc.ForwardingClientCallListener$SimpleForwardingClientCallListener |
b.edc$a$a.a (aiVar) | onHeaders(Metadata headers) |
b.edc$a$a.a (respt) | onMessage(RespT message) |
sendMessage
并没有直接出现在b.edc
中,我标出来的是b.edc$a
的父类io.grpc.t
,实际上就是ForwardingClientCall
,而ForwardingClientCall$SimpleForwardingClientCall
本身应该是没有sendMessage
这个方法的,所以上面的表格和图片数据有一点瑕疵(sendMessage不存在于b.edc$a)。
知道sendMessage
位置就好说了,以此为切入点进行追踪分析:
(agent) [2u42gim2mvd] Backtrace:
io.grpc.t.a(Native Method)
io.grpc.t.a(BL:37)
io.grpc.t.a(Native Method)
io.grpc.t.a(BL:37)
io.grpc.t.a(Native Method)
io.grpc.t.a(BL:37)
io.grpc.t.a(Native Method)
io.grpc.t.a(BL:37)
io.grpc.t.a(Native Method)
io.grpc.t.a(BL:37)
io.grpc.t.a(Native Method)
io.grpc.t.a(BL:37)
io.grpc.t.a(Native Method)
io.grpc.stub.ClientCalls.a(BL:284)
io.grpc.stub.ClientCalls.a(BL:191)
io.grpc.stub.ClientCalls.a(BL:129)
com.bilibili.lib.moss.internal.impl.failover.a.a(BL:151)
com.bilibili.lib.moss.api.MossService.blockingUnaryCall(BL:41)
com.bapis.bilibili.pgc.gateway.player.v1.PlayURLMoss.playView(BL:59)
b.eby.a(BL:51)
b.ebo.a(BL:271)
b.ebo.resolveMediaResource(BL:170)
com.bilibili.lib.media.resolver.resolve.a.a(BL:143)
com.bilibili.lib.media.resolver.resolve.MediaResolveProvider.a(BL:151)
com.bilibili.lib.media.resolver.resolve.MediaResolveProvider.call(BL:59)
android.content.ContentProvider$Transport.call(ContentProvider.java:403)
android.content.ContentResolver.call(ContentResolver.java:1772)
com.bilibili.lib.media.resolver.resolve.MediaResolveProvider.a(BL:116)
com.bilibili.lib.media.resolver.resolve.a.a(BL:93)
b.eaz.intercept(BL:36)
b.eav.a(BL:32)
b.eav.call(BL:18)
java.util.concurrent.FutureTask.run(FutureTask.java:266)
b.eas$b.call(BL:136)
java.util.concurrent.FutureTask.run(FutureTask.java:266)
java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
java.lang.Thread.run(Thread.java:764)
(agent) [2u42gim2mvd] Arguments io.grpc.t.a("<instance: java.lang.Object, $className: com.bapis.bilibili.pgc.gateway.player.v1.PlayViewReq>")
前面已经知道io.grpc.stub.ClientCalls.a(BL:129)
接受的参数就是明文的相关数据,现在通过对应函数关系更进一步得到数据走向了。
这个调用栈很奇怪,sendMessage
这里在重复调用自己,但是参数并没有变化,不知道最后怎么跳出去的...
自我调用的起始处(fVar.a(reqt)
):
private static <ReqT, RespT> void a(f<ReqT, RespT> fVar, ReqT reqt, f.a<RespT> aVar, boolean z) {
a(fVar, aVar, z);
try {
fVar.a(reqt);
fVar.a();
} catch (RuntimeException e2) {
throw a((f<?, ?>) fVar, (Throwable) e2);
} catch (Error e3) {
throw a((f<?, ?>) fVar, (Throwable) e3);
}
}
然后传入参数信息如下:
(agent) [lc8jo6jpj8d] Arguments io.grpc.stub.ClientCalls.a(
"<instance: io.grpc.f, $className: b.ede$a>",
"<instance: java.lang.Object, $className: com.bapis.bilibili.pgc.gateway.player.v1.PlayViewReq>",
"<instance: io.grpc.f$a, $className: io.grpc.stub.ClientCalls$e>",
"(none)"
)
b.ede$a
的信息:
确实有一个套娃的东西,而且个数刚好对的上io.grpc.t.a(BL:37)
的次数。
套娃的话,个人推测是在一开始的某个环节开始层层套娃,最后才这样的,这样的话,需要到原始数据附近分析才行。
[0x68da]: {delegate={delegate={delegate={delegate={delegate={delegate={delegate=o{method=MethodDescriptor{fullMethodName=bilibili.pgc.gateway.player.v1.PlayURL/PlayView, type=UNARY, idempotent=false, safe=false, sampledToLocalTracing=true, requestMarshaller=b.hyb$a@20c3efd, responseMarshaller=b.hyb$a@f1617f2}}}}}}}}}
检索fullMethodName
,很快就定位到了:
不过可惜的是这三个地方都没有被调用过。。
看来我一时半会儿是没办法从这里突破了,害,既然关注调用过程中的各种函数艰难推进,何不关注传入参数的本身,既然原始数据会会被拿去转换,那在某个地方总会用到原始数据的。
从前面的调用栈可以看到参数是com.bapis.bilibili.pgc.gateway.player.v1.PlayViewReq
类来的,那就用objection安排上 ,早就该这样的:
这下思路很清晰了,先是set了一堆东西,然后又调用了几个get开头的方法,另外注意到调用了一个com.bapis.bilibili.pgc.gateway.player.v1.PlayViewReq.writeTo(com.google.protobuf.CodedOutputStream)
方法,这个方法就很有意思了:
这是对原始数据进行了操作呀!
(agent) [qg4ht2eqvx8] Backtrace:
com.bapis.bilibili.pgc.gateway.player.v1.PlayViewReq.writeTo(Native Method)
com.google.protobuf.AbstractMessageLite.writeTo(BL:83)
b.hya.a(BL:52)
io.grpc.internal.ba.a(BL:268)
io.grpc.internal.ba.b(BL:187)
io.grpc.internal.ba.a(BL:138)
io.grpc.internal.d.a(BL:53)
io.grpc.internal.ae.a(BL:37)
io.grpc.internal.o.b(BL:463)
io.grpc.internal.o.a(BL:447)
io.grpc.t.a(BL:37)
io.grpc.t.a(BL:37)
io.grpc.t.a(BL:37)
io.grpc.t.a(BL:37)
io.grpc.t.a(BL:37)
io.grpc.t.a(BL:37)
io.grpc.t.a(BL:37)
io.grpc.stub.ClientCalls.a(BL:284)
io.grpc.stub.ClientCalls.a(BL:191)
io.grpc.stub.ClientCalls.a(BL:129)
com.bilibili.lib.moss.internal.impl.failover.a.a(BL:151)
com.bilibili.lib.moss.api.MossService.blockingUnaryCall(BL:41)
com.bapis.bilibili.pgc.gateway.player.v1.PlayURLMoss.playView(BL:59)
b.eby.a(BL:51)
b.ebo.a(BL:271)
b.ebo.resolveMediaResource(BL:170)
com.bilibili.lib.media.resolver.resolve.a.a(BL:143)
com.bilibili.lib.media.resolver.resolve.MediaResolveProvider.a(BL:151)
com.bilibili.lib.media.resolver.resolve.MediaResolveProvider.call(BL:59)
android.content.ContentProvider$Transport.call(ContentProvider.java:403)
android.content.ContentResolver.call(ContentResolver.java:1772)
com.bilibili.lib.media.resolver.resolve.MediaResolveProvider.a(BL:116)
com.bilibili.lib.media.resolver.resolve.a.a(BL:93)
b.eaz.intercept(BL:36)
b.eav.a(BL:32)
b.eav.call(BL:18)
java.util.concurrent.FutureTask.run(FutureTask.java:266)
b.eas$b.call(BL:136)
java.util.concurrent.FutureTask.run(FutureTask.java:266)
java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
java.lang.Thread.run(Thread.java:764)
(agent) [qg4ht2eqvx8] Arguments com.bapis.bilibili.pgc.gateway.player.v1.PlayViewReq.writeTo("<instance: com.google.protobuf.CodedOutputStream, $className: com.google.protobuf.CodedOutputStream$OutputStreamEncoder>")
这下可以看到io.grpc.t.a(BL:37)
最终调用了io.grpc.internal.o.a(BL:447)
可以看到数据最后在io.grpc.internal.ba.b(BL:187)
的a2
里面了。
对此做了一个分析,虽然这个分析一开始因为jadx的反编译结果不理想导致停滞,但是最后用jeb之后就明朗了很多。
后续发现数据并不是写到a2,而是aVar,所以这部分看看就好
- 中间下面这个函数【private int b(InputStream arg4, int arg5)】中执行了一个this.b.a方法;
- this.d基类是k,实际实现是j.b.a;
- 但j.b.a,也就是接口j的实现类b的静态域变量a,而右下蓝色方框的部分不是显式的,所以b.a = new b()不起作用;
- 于是接口j的实现类b的静态域变量a就是j的实现类a,原因是类b中没有a的具体实现。
- 那么调用类b的静态域变量a的a方法,最终会调用接口j的实现类a的a方法。
- 即:this.d.a() <===> j.b.a.a() <===> j.a.a()
- 关于
蓝色方框部分不是显式的
这个说法可能不太对,应该是因为this.d = j.b.a这一步没有对类b初始化,所以static()这部分代码没有起作用,然后j.b.a <===> j.a
所以a2
是个什么呢,没错它是GZIPOutputStream
,那么在b.hya.a(BL:52)
这里进行的操作可以理解为:
将明文参数通过CodedOutputStream
相关的方法写入数据,最终数据通过newInstance.flush()
传到GZIPOutputStream
,最后关闭流。CodedOutputStream
写的过程发生了什么呢?它的完整类名是com.google.protobuf.CodedOutputStream
。
简言之:明文数据protobuf编码后gzip压缩。
数据hook验证
首先是原始数据:
Java.perform(function () {
console.log("--->hook payload data");
var PlayViewReq = Java.use("com.bapis.bilibili.pgc.gateway.player.v1.PlayViewReq");
PlayViewReq.writeTo.overload("com.google.protobuf.CodedOutputStream").implementation = function (codedOutputStream) {
var data = {
epId: this.epId_,
cid: this.cid_,
qn: this.qn_,
fnver: this.fnver_,
fnval: this.fnval_,
download: this.download_,
forceHost: this.forceHost_,
fourk: this.fourk_,
spmid: this.spmid_,
fromSpmid: this.fromSpmid_,
teenagersMode: this.teenagersMode_,
preferCodecType: this.preferCodecType_,
isPreview: this.isPreview_,
roomId: this.roomId_,
};
for (var key in data) {
var text = String(key).padEnd(15, " ") + ":" + data[key].value;
console.log(text);
}
this.writeTo(codedOutputStream);
}
});
epId :316825
cid :173415955
qn :112
fnver :0
fnval :16
download :0
forceHost :0
fourk :true
spmid :pgc.pgc-video-detail.0.0
fromSpmid :search.search-result.0.0
teenagersMode :0
preferCodecType:2
isPreview :false
roomId :0
其次是protobuf编码后数据:
Java.perform(function () {
function dumpbyte(array) {
var ptr = Memory.alloc(array.length);
for (var i = 0; i < array.length; ++i)
Memory.writeS8(ptr.add(i), array[i]);
console.log(hexdump(ptr, {offset: 0, length: array.length, header: false, ansi: false}));
}
console.log("--->hook coded payload data");
var hya = Java.use("b.hya");
hya.a.overload("java.io.OutputStream").implementation = function (outputStream) {
var serializedSize = this.a(outputStream);
console.log("===>serializedSize:" + serializedSize);
return serializedSize;
};
var codedOutputStream = Java.use("com.google.protobuf.CodedOutputStream$OutputStreamEncoder");
codedOutputStream.flush.implementation = function () {
var buffer = this._buffer.value;
if (buffer.length > 0) dumpbyte(buffer);
this.flush();
};
});
bacc9f88 60 02 ab 13 10 93 bc d8 52 18 70 28 10 40 01 4a `.......R.p([email protected]
bacc9f98 18 70 67 63 2e 70 67 63 2d 76 69 64 65 6f 2d 64 .pgc.pgc-video-d
bacc9fa8 65 74 61 69 6c 2e 30 2e 30 52 18 73 65 61 72 63 etail.0.0R.searc
bacc9fb8 68 2e 73 65 61 72 63 68 2d 72 65 73 75 6c 74 2e h.search-result.
bacc9fc8 30 2e 30 00 00 0.0..
===>serializedSize:69
最后是gzip压缩后数据:
此时我发现出了问题,数据不在a2,int a3 = a(inputStream, a2);
此处的方法a如下,有一个类型转换的过程。不知道怎么解释,但总之,数据不在a2。
然后回到前面的函数b,new a()
的a是OutputStream的具体实现,不在a2就是aVar了,所以追踪了一下,果然数据传到了io.grpc.internal.ba$a.write
。
然后可以看到上一级调用是java.util.zip.GZIPOutputStream.finish(GZIPOutputStream.java:164)
:
然后找到GZIPOutputStream的源码和io.grpc.internal.ba$a
对比,看起来果然有关联:
this.f15025c.a(bArr, i, min)
的this.f15025c类型是cc,但cc是个接口,所以要看类的构造函数,发现这个参数是b.hxx
,然后最终实现类是b.hxw
:
(agent) [hkium4yrq2s] Arguments io.grpc.internal.ba.ba("<instance: io.grpc.internal.ba$c, $className: b.hxu>", "<instance: io.grpc.internal.cd, $className: b.hxx>", "<instance: io.grpc.internal.bw>")
最后是gzip压缩后数据:
Java.perform(function () {
function dumpbyte(array) {
var ptr = Memory.alloc(array.length);
for (var i = 0; i < array.length; ++i)
Memory.writeS8(ptr.add(i), array[i]);
console.log(hexdump(ptr, {offset: 0, length: array.length, header: false, ansi: false}));
}
var GZIP = Java.use("io.grpc.internal.ba$a");
GZIP.b.overload("io.grpc.internal.ba$a").implementation = function(obj) {
var field_c = obj.class.getDeclaredField("c");
field_c.setAccessible(true);
var cobj = field_c.get(obj);
var cobj = Java.cast(cobj, Java.use("b.hxw"));
var field_a = cobj.class.getDeclaredField("a");
field_a.setAccessible(true);
var buffer = field_a.get(cobj);
var buffer = Java.cast(buffer, Java.use("java.nio.ByteBuffer"));
var res = Java.array('byte', buffer.array());
dumpbyte(res);
return obj.b(obj)
}
});
b4bf5aa8 00 00 00 00 e3 98 b9 5a 58 60 f2 9e 1b 41 12 05 .......ZX`...A..
b4bf5ab8 1a 02 0e 8c 5e 12 05 e9 c9 7a 40 ac 5b 96 99 92 ....^....z@.[...
b4bf5ac8 9a af 9b 92 5a 92 98 99 a3 67 a0 67 10 24 51 9c ....Z....g.g.$Q.
b4bf5ad8 9a 58 94 9c a1 07 a1 74 8b 52 8b 4b 73 4a 40 32 .X.....t.R.KsJ@2
b4bf5ae8 09 4c 00 ff f8 26 66 45 00 00 00 00 00 00 .L...&fE......
payload打印效果
对代码进行了完善整理,最终效果:
字幕部分就略过了。
代码编写
首先是protobuf编码,在前面的过程中,header和payload都有用到protobuf,前面基本把构成信息摸清了,现在可以开始写代码了。
编写proto文件
要进行protobuf编码,先要编写proto文件,proto2理解起来相对轻松一点,以请求头的metadata为例:
syntax = "proto2";
message Metadata {
optional string accessKey = 1;
optional string mobiApp = 2;
optional string device = 3;
optional int32 build = 4;
optional string channel = 5;
optional string buvid = 6;
optional string platform = 7;
}
简单解释:
optional
表示这个字段可选,其他常用的有repeated和required,分别表示重复0次及以上、必须字段string
表示字符串类型,其他常用的有int32、int64、uint32、uint64、boolaccessKey
这个就是字段名,可以自行定义,方便阅读时理解字段内容代表的值1
是该字段的tag,也就是消息编号,在数据中这个tag就代表accessKey的含义;
是描述这个字段定义的结束,每一个字段描述末尾都要有,在开头定义的proto版本末尾也要有message
代表一条完整的消息,message可以嵌套message,一个文件中可以多个message,可以相互引用,类似string
Metadata
消息名
编码对比
由于是要用python写请求,需要转换proto文件,首先安装protobuf:
pip install protobuf
然后.proto->.py
转换命令如下:
python -m grpc_tools.protoc -I header --python_out=header header\metadata.proto
- -I 是proto的文件夹路径
- --python_out 是py文件的输出文件夹路径
- 末尾是编写的proto文件,似乎全路径、相对路径或直接文件名都可以
会在指定的header目录下生成一个metadata_pb2.py
文件,然后就可以通过导入这个文件进行编码解码操作了。
设定消息的值,并将序列化后的数据转换为base64编码的字符串(accessKey、buvid具有明感信息,已去除):
import base64
from google.protobuf.reflection import GeneratedProtocolMessageType
from header.metadata_pb2 import Metadata
def metadata_header(meta: GeneratedProtocolMessageType):
meta.accessKey = ""
meta.mobiApp = "android"
# meta.device = "" # 没有的值不要做操作
meta.build = 6070600
meta.channel = "bilibili140"
meta.buvid = ""
meta.platform = "android"
with open("test.bin", "wb") as f:
text = meta.SerializeToString()
f.write(text)
header_value = base64.b64encode(meta.SerializeToString()).decode("utf-8").rstrip("=")
print(header_value)
def main():
meta = Metadata()
metadata_header(meta)
if __name__ == "__main__":
main()
输出效果(已验证和抓包结果一致):
返回数据解码
返回数据解码需要的proto编写比较复杂,这里只给出解码效果:
模拟请求
视频接口的请求头和payload都搞定了,那就可以完成模拟请求了。
gzip header与风控
在编写代码的过程中发现gzip数据与抓包数据对不上,gzip的wiki是这么说的:
a 10-byte header, containing a magic number (1f 8b), the compression method (08 for DEFLATE), 1-byte of header flags, a 4-byte timestamp, compression flags and the operating system ID.
如此一来除了前两位之外的部分都有可能被用来做风控,所以这里要注意处理。比如最后一位是系统信息,python的是255,也就是unknown:
0 - FAT filesystem (MS-DOS, OS/2, NT/Win32)
1 - Amiga
2 - VMS (or OpenVMS)
3 - Unix
4 - VM/CMS
5 - Atari TOS
6 - HPFS filesystem (OS/2, NT)
7 - Macintosh
8 - Z-System
9 - CP/M
10 - TOPS-20
11 - NTFS filesystem (NT)
12 - QDOS
13 - Acorn RISCOS
255 - unknown
payload的header
对比发现实际的抓包数据前面还有5位,然后追踪b.edp.a
的edu.a.a
,可以找到在做什么。
简单来说就是第一位说明后面的数据是大端还是小端,后四位存gzip部分数据的长度。
部分关键代码如下(添加payload header和修改gzip header):
def add_payload_header(self, payload: bytes):
# 大端默认值1 所以是b
# payload长度为long 所以是l
# https://docs.python.org/zh-cn/3/library/struct.html
return struct.pack("!bl", 1, len(payload)) + payload
CLEAR_GZIP_HEADER = bytes([31, 139, 8, 0, 0, 0, 0, 0, 0, 0])
def gzip_compress(self, msg: GeneratedProtocolMessageType):
compressed = gzip.compress(msg.SerializeToString())
# 注意更换gzip的header
compressed = CLEAR_GZIP_HEADER + compressed[10:]
return compressed
proto文件以及模拟请求的脚本在此,bilibili-grpc-api。
后记
- jeb很强
- 多看源码
- 注意类型变换
- 不能过于相信jadx
- 观察构造函数很重要
- frida hook要熟用Cast
- 动态分析定位准确可以极大地提高效率
鸣谢
感谢以下给予帮助的大佬。
- r0ysue
- 阿泽
- nilaoda
- 我是臭猪
没有评论