ProtoBuf分析以及某方数据逆向
zsk Lv4
本文距离上次更新已过去 0 天,部分内容可能已经过时,请注意甄别。

什么是protobuf

一拿到网站,F12查看是否有相关数据的请求接口
请求体是这样的
image

请求头的类型也非常见的
image

  • application/json: JSON数据格式
  • application/octet-stream : 二进制流数据
  • application/x-www-form-urlencoded : 中默认的encType,form表单数据被编码为key/value格式发送到服务器(表单默认的提交数据的格式)
  • multipart/form-data : 需要在表单中进行文件上传时,就需要使用该格式

通过查询知道这是protobuf
参考文章:https://blog.csdn.net/dideng7039/article/details/101869819
总结在图下了

image

那如何使用protobuf?

开发者需要先编写proto文件,在proto文件中编写预期的数据类型、数据字段、默认值等
然后,通过编译器生成,编程语言对应的开发包!开发时调开发包中的对应方法进行序列化和反序列化。

image

所以请求的时候需要参数是序列化的字节序列,对接收到的返回值进行反序列化
而要实现序列化,就必须要有开发包,可是开发包是js版本的。而开发包是由proto编译而来,只要能拿到proto文件,就可以编译成任意编程的语言版本。
那就是需要通过编译好的包反编译出proto,再编译为python版本的

这里先写一个简单proto,在编译成js版本,看看里面大概的结构长什么样
下载编译器:https://github.com/protocolbuffers/protobuf/releases/
解压后把bin目录路径添加到环境变量,就可以全局使用
注意,下载低于3.21.0 的proto版本,因为原项目已将它独立出来,下载最新版本的protoc,运行js_out会缺少插件
proto除了一些基础字段,还有一些特殊字段

英文 中文 备注
enum 枚举(数字从零开始) 作用是为字段指定某”预定义值序列” enum Type {DEFAULT = 0;success = 1; fail= -1;}
message 消息体 message Student{}
repeated 数组/集合 repeated Student student = 1
import 导入定义 import “protos/other_protos.proto”
// 注释 //用于注释
extend 扩展 extend Student {}
package 包名 相当于命名空间,用来防止不同消息类型的明明冲突

现在写一个简单的proto文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
syntax = "proto3"; // 定义proto的版本

enum Gender{
boy=0;
girl=1;
}
enum Score{
DEFAULT = 0;
success = 1; // 及格
fail = -1; // 不及格
}
message Student {
string name = 1; // 姓名
int32 age = 2; // 年龄
Gender gender = 3; //性别
message Subject {
string name = 1; // 学科名称
Score score = 2; // 分数
}
repeated Subject subject = 4; // 学科
}

编译为JS包

1
2
protoc --js_out=. .\test.proto3
protoc --js_out=import_style=commonjs,binary:. test.proto

两条语句都可以,第一条会拆分成多个文件,第二条是合并成一个,推荐使用第二条
头部就能看到定义好的几个大的对象
image
可以大概看下代码,截一段比较重要的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* Serializes the given message to binary data (in protobuf wire
* format), writing to the given BinaryWriter.
* @param {!proto.Student} message
* @param {!jspb.BinaryWriter} writer
* @suppress {unusedLocalVariables} f is only used for nested messages
*/
proto.Student.serializeBinaryToWriter = function(message, writer) {
var f = undefined;
f = message.getName();
if (f.length > 0) {
writer.writeString(
1,
f
);
}
f = message.getAge();
if (f !== 0) {
writer.writeInt32(
2,
f
);
}
f = message.getGender();
if (f !== 0.0) {
writer.writeEnum(
3,
f
);
}
f = message.getSubjectList();
if (f.length > 0) {
writer.writeRepeatedMessage(
4,
f,
proto.Student.Subject.serializeBinaryToWriter
);
}
};

这一段序列化的代码中出现了如下的方法名:

getName, writeString
getAge, writeInt32
getGender, writeEnum
getSubjectList, writeRepeatedMessage

这一整个判断,这意味 Student中定义了四个数据变量, 序号为1, 2,3,4,而数据类型和变量名可以根据其调用的方法推出

序号为1的数据类型为String,变量名为name
序号为2的数据类型为Int32,变量名为age
序号为3的数据类型为Enum, 变量名为gender
序号为4的数据类型为Message,变量名为subject,Repeated下面讲

字符串和整数型一看就明了,不做过多解释,下面了解MessageEnum
Message是什么数据类型?
简单的理解,可以把message看作是一个类,在其中定义的变量就是类属性
在序号为4的subject判断中有这样一行代码

1
proto.Student.Subject.serializeBinaryToWriter

再来看看Student的

1
proto.Student.serializeBinaryToWriter

到这里可知,Subject定义在Student里面且类型是Message
在定义序号为4的数据时,数据类型就是Subject,并且是可重复的!
所以才会出现这样一个方法writeRepeatedMessage,并且严格来说,序号为4的数据是自定义的Message数据类型,且是可重复的
Message类型的Subject被repeated修饰,即Subject是一个包含多个Subject实例的数组
Enum是什么数据类型?
枚举类型,在值为限定的情况下,比如性别除了男就是女。可以理解为单选框,这里还有个注意的,枚举类型。必须要有为0的默认选项
总而言之呢,看见writeEnum就知道这个数据为Enum类型
repeated也可以修饰Enum,其对应的JS写操作的方法为writePackedEnum
被repeated修饰的enum类型,则好似的多选框,至少选择一个,可选择多个
小结一下:
被repeated修饰的message类型的数据,看作是一个包含任意个某message类型数据的数组
被repeated修饰的enum类型的数据,看作是一个包含任意个整数类型数据的整型数组

调试JS反写proto

目标网站:aHR0cHM6Ly9zLndhbmZhbmdkYXRhLmNvbS5jbi9wYXBlcj9xPXB5dGhvbg==
将接口的请求地址复制 /SearchService.SearchService/search ,打 XHR/fetch 断点
断住后查看堆栈,有SearchService跟进去打断点看看
image
image
看下这些方法的命名,序列化(serialize)、反序列化(deserialize),基本断定就在这个js文件里,但是这个js有几万行代码,不可能仔细去看也没必要。
看到明显的prototype字样,直接搜proto的特征

toObject 将获取到的数据转成结构化数据
deserializeBinary 二进制数据转换成数组结构(反序列化 | 获取到的数据需要Uint8Array转成二进制)
deserializeBinaryFromReader 根据规则,将二进制数据转换成数组结构
serializeBinary 将数据转成二进制(序列化)
serializeBinaryToWriter 根据规则,将数据转换成二进制数据(序列化)

image
可以肯定就是proto了
一步步跟进后,到序列化发包的位置
image
在这里,直接就可以看出其基本结构

1
2
3
4
message SearchService {
message SearchRequest {
}
}

继续调试。
image
这里可以看出SearchRequest定义了两个变量,分别是序号为1的message类型的CommonRequest和序号为2的enum类型的InterfaceType。
根据SearchService.CommonRequest可知,CommonRequest定义在SearchService中
所以,proto文件现在是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
syntax = "proto3"; // 定义proto的版本

message SearchService {
message SearchRequest {
CommonRequest commonRequest = 1; // 任意变量名
InterfaceType interfaceType = 2; // 任意变量名
}
message CommonRequest {

}
enum InterfaceType{
DEFAULT = 0; // 定义了什么不知道,但是enum必须有一个值就是0
}
}

关于变量名是什么,这个其实不重要
继续往下调试,进入到了CommonRequest
image
根据方法名,直接就可以反写出CommonRequest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
  message SearchRequest {
CommonRequest commonRequest = 1; // 任意变量名
InterfaceType interfaceType = 2; // 任意变量名
}
message CommonRequest {
string searchType = 1;
string searchWord = 2;
SearchSort searchSort = 3;
repeated Second second = 4;
int32 currentPage = 5;
int32 pageSize = 6;
SearchScope searchScope = 7;
repeated SearchFilter searchFilter = 8;
bool languageExpand = 9;
bool topicExpand = 10;
}
message SearchSort {

}
message Second {

}
enum InterfaceType{
TypeDefault = 0; // 定义了什么不知道,但是enum必须有一个值就是0
}
enum SearchScope{
ScopeDefault = 0;
}
enum SearchFilter {
FilterDefault = 0;
}
}

SearchSort和Second都是在SearchService定义的,Ctrl + F搜索
SearchService.SearchSort.serializeBinaryToWriter
image
SearchService.Second.serializeBinaryToWriter
image
补齐字段,请求接口的proto文件就算写完了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
syntax = "proto3"; // 定义proto的版本

message SearchService {
message SearchRequest {
CommonRequest commonRequest = 1; // 任意变量名
InterfaceType interfaceType = 2; // 任意变量名
}
message CommonRequest {
string searchType = 1;
string searchWord = 2;
SearchSort searchSort = 3;
repeated Second second = 4;
int32 currentPage = 5;
int32 pageSize = 6;
SearchScope searchScope = 7;
repeated SearchFilter searchFilter = 8;
bool languageExpand = 9;
bool topicExpand = 10;
}
message SearchSort {
string field = 1;
Order order = 2;
enum Order {
OrderDefault = 0;
}
}
message Second {
string field = 1;
string value = 2;
}
enum InterfaceType{
TypeDefault = 0; // 定义了什么不知道,但是enum必须有一个值就是0
}
enum SearchScope{
ScopeDefault = 0;
}
enum SearchFilter {
FilterDefault = 0;
}
}

对于所有的enum枚举类,至少填充一个默认值0,且变量名唯一
有的情况,枚举类含有哪些字段,可以在代码中直接看到,就照抄写进去。
看不到的,给个唯一变量名,默认值为0即可
现在还差一个源数据,即我们需要知道待编译的源数据是什么样子的?
使用fiddler进行抓包查看请求参数
抓到包后查看HexView,黑色部分就是请求体,里面也可以看到我们搜素的关键词python
image
选中,右键保存为字节文件也就是bin后缀,这里要注意,前5个字节表示请求体的长度,从第6个字节开始到结束刚好就是0x1A
image
字节数据是可以通过protoc编译器解码出来的

1
2
3
4
5
6
7
8
9
>protoc --decode_raw < get_req.bin
1 {
1: "paper"
2: "python"
5: 2
6: 20
8: "\000"
}
2: 1

与上面编写好的proto文件进行对比
image
像有些没包含到的字段,是请求的时候页面没做一些条件筛选,就没触发到某些字段
实际传输时,简单的看,键就是proto中定义的序号,这就是之前提到的 变量名是什么根本不重要,变量名只是方便开发者开发时便于理解与调用。(传输一个数字远比传输一个字符串更有效率)
完全还原proto文件是不需要的,构造出这个请求参数,获取这个接口的响应内容就可以了

实现请求

编译proto为python包,构建参数,序列化参数,发送请求

1
protoc --python_out=. ./search.proto

目录下生成了search_pb2.py 拖入项目中,需要使用时就调用即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import search_pb2 as pb  # 导入包

search_request = pb.SearchService.SearchRequest() # 实例化对象
# 按上面解析数据,按照对应的属性设置值
# 字符串,数字型的都是直接赋值
search_request.commonRequest.searchType = 'paper'
search_request.commonRequest.searchWord = 'python'
search_request.commonRequest.currentPage = 2
search_request.commonRequest.pageSize = 20
# repeated修饰的messsage类型和enum类型,则需要稍微多几个步骤
search_request.commonRequest.searchFilter.append(0)
search_request.interfaceType = 1
form_data = search_request.SerializeToString()
print(form_data)
# 保存数据玮bin文件供后续对比使用
with open('me.bin', mode="wb") as f:
f.write(form_data)
print(search_request.SerializeToString().decode())

至此,请求参数的序列化已经是完成了
image
发送请求完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import search_pb2 as pb  # 导入包
import requests

search_request = pb.SearchService.SearchRequest() # 实例化对象
# 按上面解析数据,按照对应的属性设置值
# 字符串,数字型的都是直接赋值
search_request.commonRequest.searchType = 'paper'
search_request.commonRequest.searchWord = 'python'
search_request.commonRequest.currentPage = 2
search_request.commonRequest.pageSize = 20
# repeated修饰的messsage类型和enum类型,则需要稍微多几个步骤
search_request.commonRequest.searchFilter.append(0)
search_request.interfaceType = 1
form_data = search_request.SerializeToString()
print(form_data)
# 保存数据玮bin文件供后续对比使用
# with open('me.bin', mode="wb") as f:
# f.write(form_data)
# print(search_request.SerializeToString().decode())

bytes_head = bytes([0, 0, 0, 0, len(form_data)])
print(bytes_head+form_data)
headers = {
"Accept": "*/*",
"Accept-Language": "zh-CN,zh;q=0.9,zh-TW;q=0.8",
"Content-Type": "application/grpc-web+proto",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36",
}
url = "https://*********/SearchService.SearchService/search"
response=requests.post(url,headers=headers,data=bytes_head+form_data)
print(response.content)

响应处理

我们构造了请求的proto文件,并成功用python发包获得了数据,但是得到的数据和f12得到的数据是一样的乱码如下图
image
其实这个也是protobuf格式,发过去的是protobuf格式,收到的也是protobuf格式,只是它是以二进制序列化格式传输的,所以看上去像乱码.
接下来会带来两种方法:①直观但有点复杂,②便捷但不太直观

方法一

写对应的响应的proto文件,和发包一样。当然可以和发包写在一起。
老规矩,还是打断点从堆栈进行分析,根据发包的堆栈主要看app开头的js,因为chunk开头的是基本库,很少在里面做手脚,一般都是在自写的js里面做加密或其他操作。
image
一步步调试后,
image
异步然后获得了值去.toObject,这个toObject就是proto文件转js的时候会产生的一个api函数接口,可以简单使用protoc去尝试转化成js看看。
这里不好跟进,直接全局搜索一下:proto.SearchService.SearchResponse
这里接受响应后需要把二进制数据进行反序列化,那么就会用到下面的api
deserializeBinary——deserializeBinaryFromReader重点核心
完整的就是 proto.SearchService.SearchResponse.deserializeBinaryFromReader
image
一下子就定位到了,和请求的一样理解,只是他现在变成了case语句来表示序号位置,read后面的类型来表示类型。
序号4有个message,进去查看
image
这个返回的数据量太大了,标号也特别的多,有没有什么更好的方法得到proto文件呢?
那就是自写ast,然后用ast来处理这种switch语句。这里直接使用渔歌写好的ats插件,文末附上链接,网站js有些小更新,之前的可能有些小报错,小小的修改了一下
这里把整个js复制出来命名为test.js,先安装babel解析库在当前目录下

1
npm install @babel/core --save-dev

执行ast代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
const parser = require("@babel/parser");
// 为parser提供模板引擎
const template = require("@babel/template").default;
// 遍历AST
const traverse = require("@babel/traverse").default;
// 操作节点,比如判断节点类型,生成新的节点等
const t = require("@babel/types");
// 将语法树转换为源代码
const generator = require("@babel/generator");
// 操作文件
const fs = require("fs");

//定义公共函数
function wtofile(path, flags, code) {
var fd = fs.openSync(path,flags);
fs.writeSync(fd, code);
fs.closeSync(fd);
}

function dtofile(path) {
fs.unlinkSync(path);
}

var file_path = 'test.js'; //你要处理的文件
var jscode = fs.readFileSync(file_path, {
encoding: "utf-8"
});

// 转换为AST语法树
let ast = parser.parse(jscode);
let proto_text = `syntax = "proto3";

// protoc --python_out=. app_proto2.proto

`;

traverse(ast, {
MemberExpression(path){
if(path.node.property.type === 'Identifier' && path.node.property.name === 'deserializeBinaryFromReader' && path.parentPath.type === 'AssignmentExpression'){
let id_name = path.toString().split('.').slice(1, -1).join('_');
path.parentPath.traverse({
VariableDeclaration(path_2){
if(path_2.node.declarations.length === 1){
path_2.replaceWith(t.expressionStatement(
t.assignmentExpression(
"=",
path_2.node.declarations[0].id,
path_2.node.declarations[0].init
)
))
}
},
SwitchStatement(path_2){
for (let i = 0; i < path_2.node.cases.length - 1; i++) {
let item = path_2.node.cases[i];
let item2 = path_2.node.cases[i + 1];
if(item.consequent.length === 0 && item2.consequent[1].expression.type === 'SequenceExpression'){
item.consequent = [
item2.consequent[0],
t.expressionStatement(
item2.consequent[1].expression.expressions[0]
),
item2.consequent[2]
];
item2.consequent[1] = t.expressionStatement(
item2.consequent[1].expression.expressions[1]
)
}else if(item.consequent.length === 0){
item.consequent = item2.consequent
}else if(item.consequent[1].expression.type === 'SequenceExpression'){
item.consequent[1] = t.expressionStatement(
item.consequent[1].expression.expressions[1]
)
}
}
}
});
let id_text = 'message ' + id_name + ' {
';
let let_id_list = [];
try{
// console.log(path.parentPath.node.right.body.body[0].body.body[0].cases.length);
for (let i = 0; i < path.parentPath.node.right.body.body[0].body.body[0].cases.length; i++) {
let item = path.parentPath.node.right.body.body[0].body.body[0].cases[i];
if(item.test){
let id_number = item.test.value;
let key = item.consequent[1].expression.callee.property.name;
let id_st, id_type;
if(key.startsWith("set")){
id_st = "";
}else if(key.startsWith("add")){
id_st = "repeated";
}else{
// map类型,因为案例中用不到,所以这里省略
continue
}
key = key.substring(3, key.length);
id_type = item.consequent[0];
if(id_type.expression.right.type === 'NewExpression'){
id_type = generator.default(id_type.expression.right.callee).code.split('.').slice(1).join('_');
}else{
switch (id_type.expression.right.callee.property.name) {
case "readString":
id_type = "string";
break;
case "readDouble":
id_type = "double";
break;
case "readInt32":
id_type = "int32";
break;
case "readInt64":
id_type = "int64";
break;
case "readFloat":
id_type = "float";
break;
case "readBool":
id_type = "bool";
break;
case "readPackedInt32":
id_st = "repeated";
id_type = "int32";
break;
case "readBytes":
id_type = "bytes";
break;
case "readEnum":
id_type = "readEnum";
break;
case "readPackedEnum":
id_st = "repeated";
id_type = "readEnum";
break;
}
}
if(id_type === 'readEnum'){
id_type = id_name + '_' + key + 'Enum';
if(let_id_list.indexOf(id_number) === -1){
id_text += '\tenum ' + id_type + ' {
';
for (let j = 0; j < 3; j++) {
id_text += '\t\t' + id_type + 'TYPE_' + j + ' = ' + j + ';
';
}
id_text += '\t}

';
id_text += '\t' + id_st + ' ' + id_type + ' ' + key + ' = ' + id_number + ';
';
let_id_list.push(id_number)
}
}else{
if(let_id_list.indexOf(id_number) === -1){
id_text += '\t' + id_st + ' ' + id_type + ' ' + key + ' = ' + id_number + ';
';
let_id_list.push(id_number)
}
}
}
}
}catch(e){
}

id_text += '}

';
proto_text += id_text
}
}
});

wtofile('app_proto3.proto', 'w', proto_text);

这个ast代码单纯只是针对这个站点,其他站点也是类似分析。
运行后生成了app_proto3.proto文件,打开看一面有一些报错,如下图,渔歌文章也讲清楚了原因,因为对象调用deserializeBinaryFromReader方法的时候,ast代码处理对象无法确定,所以就没加载到。
image
我们在调试里面,搜索关键词ExportResponse.deserializeBinaryFromReader
image
image
跟进去就能找到s对象是什么,补上就行,其他的报错也是这样的操作
得到了proto文件后进行编译成python

1
protoc --python_out=. ./app_proto3.proto

然后发个请求试一试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import app_proto3_pb2 as pb
import requests

search_request = pb.SearchService_SearchRequest() # 实例化对象
# 按上面解析数据,按照对应的属性设置值
# 字符串,数字型的都是直接赋值
search_request.Commonrequest.SearchType = 'paper'
search_request.Commonrequest.SearchWord = 'python'
search_request.Commonrequest.CurrentPage = 2
search_request.Commonrequest.PageSize = 20
# repeated修饰的messsage类型和enum类型,则需要稍微多几个步骤
search_request.Commonrequest.SearchFilterList.append(0)
search_request.InterfaceType = 1
form_data = search_request.SerializeToString()
print(form_data)
# 保存数据玮bin文件供后续对比使用
# with open('me.bin', mode="wb") as f:
# f.write(form_data)
# print(search_request.SerializeToString().decode())

bytes_head = bytes([0, 0, 0, 0, len(form_data)])
print(bytes_head + form_data)
headers = {
"Accept": "*/*",
"Accept-Language": "zh-CN,zh;q=0.9",
"Content-Type": "application/grpc-web+proto",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36",
}
url = "https://*********.com.cn/SearchService.SearchService/search"
response = requests.post(url, headers=headers, data=bytes_head + form_data)
# print(response.text)

search_response = pb.SearchService_SearchResponse()
search_response.ParseFromString(response.content[5:])
print(search_response)

可以看到很直观,取值也方便。
image
上面之所以从响应的第六位字节开启取,是跟上面发包一样的,前五个字节表示请求头的长度
下面是proto的核心,序列化和反序列化
serializeBinary——serializeBinaryFromReader重点核心
deserializeBinary——deserializeBinaryFromReader重点核心

方法二

使用python应对protobuf的第三方库:blackboxprotobuf
安装命令:pip install blackboxprotobuf
调用核心函数 :blackboxprotobuf.decode_message(Byte类型数据),进行解protobuf格式数据
image
上面是数据对应结构位置,下面是类型对应结构位置
虽然拿到了数据,只是位置序号加内容,我们其实要靠猜才能知道是什么,这种就不需要去写proto文件
两种方式都可以,喜欢哪种用哪种

相关资料参考

https://blog.csdn.net/dideng7039/article/details/101869819
https://blog.csdn.net/qq_35491275/article/details/111721639
https://mp.weixin.qq.com/s/DzCz66_Szc7vfG6bpl956w
https://blog.csdn.net/qq_56881388/article/details/128612717

 评论