某方数据平台的逆向分析-------学会逆向protobuf
某方数据平台的逆向分析-------学会protobuf# 声明
**本文章中所有内容仅供学习交流,相关链接做了脱敏处理,若有侵权,请联系我立即删除!**
# 一、前期准备:
## 1.1 了解protobuf协议:
Protobuf(Protocol Buffer)是 Google 开发的一套数据存储传输协议,为二进制序列化格式,可用作 JSON 或 XML 等格式的更有效替代方案。开发人员可以在 .proto 文件中定义消息格式,并使用 protobuf 编译器(protoc)按他们选择的语言生成消息处理程序。Protobuf 编码是二进制的,与 json、xml 不同,它不是可读的,也不容易手动修改。Protobuf 能够把数据压缩得很小,所以传输数据就比 xml 和 json 快几倍,使用其有利于提高效率,但同时增加了分析或修改数据的难度。
序列化 (Serialization) 是指将对象转换为字节序列的过程,在序列化期间,对象将其当前状态写入到临时或持久性存储区,以后,可以通过从存储区中读取或反序列化对象的状态,重新创建该对象。
protobuf知识补充见:https://www.52pojie.cn/thread-1735975-1-1.html
## 1.2 Protobuf 环境配置:
下载protoc文件:[找对应版本下载](https://github.com/protocolbuffers/protobuf/releases/tag/v21.12)
然后添加到环境变量,便于之后使用。
# 二、目标网站:
aHR0cHM6Ly9zLndhbmZhbmdkYXRhLmNvbS5jbi9wYXBlcj9xPSVFNyU4OCVBQyVFOCU5OSVBQg==
打开网站(以第2页为例),找到我们这次需要分析的包。
返回数据也是乱码:
经过查资料,观察返回的content-type为application/grpc-web+proto 是以protobuf数据结构传输的。
我们用fiddler抓包看看(黑色16进制数据是发送的数据,蓝色是请求头数据)
我们右键将黑色请求数据(第6位开始括)部分保存下来,为bin格式后面分析时候有用,为什么从第6位开始,后面会分析。
# 三、开始分析:
我们下个xhr断点看看,看看数据是怎么来的。
可以看到数据是以字节集的形式发出的,与fd抓包的16进制数据是一一对应的。
接下来,我们看看数据是怎么来的?跟e来到上一个栈,可以发现是a赋值给e的。
我们在e赋值的地方下断点,看看a怎么来的,重新调试一次,主要看a怎么构成的。
发现其实核心就是9681行到9690行赋值这一段。
```javascript
for(n = (e = (0,n.a)(e.getRequestMessage())).length,
f = ,
a = new Uint8Array(5 + n),
s = 3; 0 <= s; s--)
f = n % 256,
n >>>= 8;
if (a.set(new Uint8Array(f), 1),
a.set(e, 5),
e = a,
```
## 我们一句句分析:
### 先for循环部分:
`e = (0,n.a) (e.getRequestMessage())`
得到的是一个unit8Array数组,刚刚好就是我们的数据从5开始往后数。
`n = (e = (0,n.a)(e.getRequestMessage())).length`
这里得到这个数组长度是26。
`f = ``
这里类似设置了一个头,4位空数组
`a = new Uint8Array(5 + n)`
这里a的初始化,因为我们知道最后a是一个Uint8Array的数组,他的长度是5+n。
`s = 3; 0 <= s; s--`
这里可以确定整个循环的次数,4次。
因为js是以;为结束的,所以相当于下列语句循环了4次,那这步操作是干嘛的呢?
```javascript
f = n % 256,
n >>>= 8;
```
给f数组各位赋值的,刚刚好f数组就是4位。所以说先给f的第四位赋值相当于f, 再 n进行>>>=移位操作
得到
```javascript
f =26%256=26 26 >>>8=0 =>n=0
f = 0%256 =0 0>>>8=0 =>n=0
f = 0%256 =0 0>>>8=0 =>n=0
f = 0%256 =0 0>>>8=0 =>n=0
```
`f=`
### 后面就是给a赋值了:
`a.set(new Uint8Array(f), 1)`
a是数组,然后查mdn文档,可知set方法的作用:
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/TypedArray/set
**`set()`** 方法用于从指定数组中读取值,并将其存储在类型化数组中。
有两个参数,typedarray,offset(可选,默认为 0)
typedarray对应`new Uint8Array(f) `
offset对应 1
相当于从下标1开始,给a数组插入值f。
所以得到
这里核心就是这个set方法,不懂查一下就理解了。
`a.set(e, 5)`
相当于从数组下标5开始把e的值赋值给a
ok,我们到这里已经把a怎么来的理解清楚了,也明白了前面5位是怎么来的。
但是,我们还是没接触到`protobuf`啊,只是知道了数据得到的大概过程。核心就在`(0,n.a)(e.getRequestMessage())`里面。
发现了我们的要讲的核心,protobuf。
看文章,看官方文档可以知道要构造这个protobuf格式数据,就是刚刚我们得到的那个数组,我们要先写proto文件,然后生成对应语言的proto版本,然后发包。
# 四、写proto文件(核心)
跟进n.a方法
发现了写protobuf数据格式的关键函数:**`serializeBinary`**
跟进去
**关键点:**
**根据serializeBinaryToWriter关键函数,可以得到此结构分层,跟进去可以得到包含几个信息**
write + (信息类型)函数,第一个参数代表是第几个。(如下图圈主部分)
看到writeMessage,后面有serializeBinaryToWriter关键函数,跟进去,就能再次得到此message的结构层次,一样分析,就能得出完整结构。
packedenum用repeated关键字表示。
一个个分析按顺序写proto文件
```protobuf
syntax = "proto3";
message SearchService {
enum InterfaceType{
A=0;
}
enum SearchScope{
B = 0;
}
enum SearchFilter {
C = 0;
}
enum Order{
D=0;
}
message SearchSort{
string Field =1;
Order Order=2;
}
message SecondsList{
string Field=1;
string Value=2;
}
message Commonrequest{
string SearchType=1;
string SearchWord=2;
SearchSort searchSort=3;
repeated SecondsList secondslist=4;
int32 CurrentPage=5;
int32 PageSize=6;
SearchScope searchscope=7;
repeated SearchFilter searchFilter = 8;
bool LanguageExpand=9;
bool TopicExpand=10;
}
message SearchRequest {
Commonrequest commonrequest = 1; // 任意变量名
InterfaceType interfaceType = 2; // 任意变量名
}
}
```
命令行生成可python操作的protobuf文件
```bash
protoc --python_out=. ./test.proto
```
数据分析:fd抓包下载bin,然后命令行 protoc --decode_raw < 1.bin执行,解析protobuf数据结构
然后对应上面的结构找到对应变量,用python赋值运行:
```python
import test_pb2 as pb #导入包
SearchRequest= pb.SearchService.SearchRequest() #实例化对象
#按上面解析数据,按照对应的属性设置值
#字符串,数字型的都是直接赋值
SearchRequest.commonrequest.SearchType='paper'
SearchRequest.commonrequest.SearchWord='爬虫'
SearchRequest.commonrequest.CurrentPage=2
SearchRequest.commonrequest.PageSize=20
#repeated修饰的messsage类型和enum类型,则需要稍微多几个步骤
SearchRequest.commonrequest.searchFilter.append(0)
SearchRequest.interfaceType=1
form_data = SearchRequest.SerializeToString()
print(form_data)
#保存数据玮bin文件供后续对比使用
with open('me.bin', mode="wb") as f:
f.write(form_data)
```
**注意:**
字符串,数字型的都是直接赋值
repeated修饰的messsage类型和enum类型,则需要稍微多几个步骤
```python
# 可重复message类型,需要调用一个add方法,然后将对应字段赋值
seconds = search_request.commonrequest.Second.add()
seconds.field = "Type"
seconds.value = '"Thesis"'
# 可重复enum枚举类型
search_request.commonrequest.searchFilter.append(0)
```
自己构造的和上面对比,发现完全一致,说明分析正确。
用python发包,根据上面的分析,发现需要补头5位
```python
bytes_head = bytes()
```
完整代码(获得数据):
```python
import requests
import test_pb2 as pb
SearchRequest= pb.SearchService.SearchRequest()
SearchRequest.commonrequest.SearchType='paper'
SearchRequest.commonrequest.SearchWord='爬虫'
SearchRequest.commonrequest.CurrentPage=2
SearchRequest.commonrequest.PageSize=20
SearchRequest.commonrequest.searchFilter.append(0)
SearchRequest.interfaceType=1
form_data = SearchRequest.SerializeToString()
print(form_data)
# with open('me1.bin', mode="wb") as f:
# f.write(form_data)
# print(SearchRequest.SerializeToString().decode())
bytes_head = bytes()
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",
"Origin": "https://*********",
"Referer": "https://*********/paper",
"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文件,请求头怎么一一对应,下一节,我们将对响应进行处理,找出相应的proto文件。
# 五、下部分前言
上一节,我们构造了请求的proto文件,并成功用python发包获得了数据,但是得到的数据和f12得到的数据是一样的乱码如下图:
其实这个也是protobuf格式,发过去的是protobuf格式,收到的也是protobuf格式,只是它是以二进制序列化格式传输的,所以我们看上去像乱码。
那么我们要怎么让它变好看呢?或者怎样好处理呢?
接下来会带来两种方法:①便捷但不太直观,②直观但有点复杂。
大家因人而异选择处理就好,喜欢用哪个就用哪个。
# 六、介绍处理的两种方法
## 1.方法一:直接使用第三方库
直接使用python应对protobuf的第三方库:`blackboxprotobuf`
安装命令:`pip install blackboxprotobuf`
调用核心函数 :`blackboxprotobuf.decode_message`(**Byte类型数据**)
进行解protobuf格式数据变成我们好理解,直观的数据。
返回值有两个:第一个是数据(和上一节用命令行解出来的那种格式类似,对应着proto文件的位置),第二个是对应位置的类型
这里我们传`response.content`进去就好了,但是你会发现报错了:
因为返回值我们还没分析,它发过来的有一部分是头信息,不在protobuf格式里面,所以解析会报错。
我先展示一下正确结果,给大家看看效果,第二种方法的时候,我会详细讲整个过程:
所以我们可以分析data的结构,找到我们需要的数据,然后取值。
取值就和取json数据一样:`对象[键名]` 这里就是 `data['2'] ===>爬虫`
取你想要的,当然要拿出来数据到json解析的网站解析一下,然后分析:
这里推荐网站:https://spidertools.cn/#/
你可以看到,虽然拿到了数据,只是位置序号加内容,我们其实要靠猜才能知道是什么。
这就是我所说的便捷但不直观。
当然,blackboxprotobuf库还有其他函数api接口,你们可以查阅其他文章了解使用。
## 2.方法二:写对应的响应的proto文件
写对应的响应的proto文件,和发包一样。当然可以和发包写在一起。
然后同样的用命令行编译成python版本的:
```bash
protoc --python_out=. ./test.proto
```
然后一样的创建实例对象,然后使用`.ParseFromString`(**Byte类型数据**)函数就能解析了
```python
search_response = test_proto2_pb2.SearchService_SearchResponse()
search_response.ParseFromString(response.content)
print(search_response)
```
这里用的是渔歌之前文章的例子做演示,我们看看效果:
是不是直观了很多,但是核心还是写proto文件,也是难点。然后分析为什么这里是取部分数据做解析。接下来,我们讲怎么构造响应的proto文件,然后为什么数据是从第6位(数组下标5)开始取。
我们f12开始分析,响应其实是最难调试的点,定位也很难,所以我们可以从请求的过程堆栈入手,说不定可以有所启发,发现可疑位置。
下xhr断点,跟着堆栈走,主要看app开头的js,因为chunk开头的是基本库,很少在里面做手脚,一般都是在自写的js里面做加密或其他操作。
可以发现h这里有点可疑,异步然后获得了值去.toObject,这个toObject就是proto文件转js的时候会产生的一个api函数接口,大家可以简单使用protoc去尝试转化成js看看。
> (下载低于3.21.0 的protoc版本,因为原项目已将它独立出来,下载最新版本的protoc,运行js_out会缺少插件)
```bash
protoc --js_out=import_style=commonjs,binary:. 你的proto文件名.proto
```
然后下断点执行,你会看到下面数据如图:
发现和之前请求的是不是很像,其实这已经是解包好的了,所以应该在前面,但是异步我们不好跟了,或者我们在then那里下一个断点,然后单步跟,我就用另一种方式了,我们复制这一段,全局搜索一下:proto.SearchService.SearchResponse
会找到我这节要说的写响应,返回值proto文件的核心关键词:
**deserializeBinary------deserializeBinaryFromReader**(**重点核心**)
其实和请求包的关键词很像,就前面加了de,相当于解的意思。这里要多亏了志远大佬的指导,因为其实找响应的时候没一点头绪,然后很多文章都没讲清楚解包的关键词,异步也不太好跟,然后他稍微指点了一下,然后下断点就能定位了。
我们下一个断点看看,放开,重新点击:
我们成功来到这个位置,和fiddler数据对比一下:
发现是从第6位开始解包,所以就解决了上面我们的疑问,为什么从第6位开始取数据。
刚刚不是提到了两个关键词吗?
**deserializeBinary------deserializeBinaryFromReader**(**重点核心**)
和请求的一样理解,只是他现在变成了case语句来表示位置,read后面的类型来表示类型。
也是一层一层,慢慢的可以跟出来,当然,这个返回的数据量太大了,标号也特别的多,我们刚刚也看到了,所以有没有什么更好的方法得到proto文件呢?
那就是自写ast,然后用ast来处理这种switch语句。先安装babel解析库在当前目录下
```bash
npm install @babel/core --save-dev
```
我对ast还不是很熟悉,我就采用渔歌之前写好的(文末会附上链接),因为网站js也有小更新,之前的使用会报错。
按照文章,我们先把这一个js保存起来,我这里保存成1.js,运行
发现报错了,我稍微调试修改一下
原来的:
```js
path.parentPath.node.right.body.body.body.body.cases.length
```
修改为:
```js
path.parentPath.node.right.body.body.body.body.cases.length
```
还加了try处理,因为,有些是取不到length的,我也没太仔细分析原因,只是单纯从报错入手,然后小修改了一下。
proto2改成了proto3,optional关键字删除。
```javascript
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 = '1.js'; //你要处理的文件
var jscode = fs.readFileSync(file_path, {
encoding: "utf-8"
});
// 转换为AST语法树
let ast = parser.parse(jscode);
let proto_text = `syntax = "proto3";\n\n// protoc --python_out=. app_proto2.proto\n\n`;
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.id,
path_2.node.declarations.init
)
))
}
},
SwitchStatement(path_2){
for (let i = 0; i < path_2.node.cases.length - 1; i++) {
let item = path_2.node.cases;
let item2 = path_2.node.cases;
if(item.consequent.length === 0 && item2.consequent.expression.type === 'SequenceExpression'){
item.consequent = [
item2.consequent,
t.expressionStatement(
item2.consequent.expression.expressions
),
item2.consequent
];
item2.consequent = t.expressionStatement(
item2.consequent.expression.expressions
)
}else if(item.consequent.length === 0){
item.consequent = item2.consequent
}else if(item.consequent.expression.type === 'SequenceExpression'){
item.consequent = t.expressionStatement(
item.consequent.expression.expressions
)
}
}
}
});
let id_text = 'message ' + id_name + ' {\n';
let let_id_list = [];
try{
// console.log(path.parentPath.node.right.body.body.body.body.cases.length);
for (let i = 0; i < path.parentPath.node.right.body.body.body.body.cases.length; i++) {
let item = path.parentPath.node.right.body.body.body.body.cases;
if(item.test){
let id_number = item.test.value;
let key = item.consequent.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;
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 + ' {\n';
for (let j = 0; j < 3; j++) {
id_text += '\t\t' + id_type + 'TYPE_' + j + ' = ' + j + ';\n';
}
id_text += '\t}\n\n';
id_text += '\t' + id_st + ' ' + id_type + ' ' + key + ' = ' + id_number + ';\n';
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 + ';\n';
let_id_list.push(id_number)
}
}
}
}
}catch(e){
}
id_text += '}\n\n';
proto_text += id_text
}
}
});
wtofile('app_proto3.proto', 'w', proto_text);
```
这个ast代码单纯只是针对这个站点,其他站点也是类似分析。
运行完后,我们得到了app_proto3.proto文件,打开来,我们发现了报错,如下图,渔歌文章也讲清楚了原因,因为对象调用`deserializeBinaryFromReader`方法的时候,ast代码处理对象无法确定,所以就没加载到。
我们在调试里面,搜索关键词ExportResponse.deserializeBinaryFromReader
就能找到位置,根据我上面说的上下级关系,然后跟进去
就能找到s代表的是什么了。
然后我们依次像这样把报错补好就行了。
得到了proto文件,基本上我们的任务就完成了,我们发包试试吧。
可以看到很直观,取值也方便。我们的protobuf之旅也基本上结束了!
完整代码:
```python
import requests
import app_proto3_pb2 as pb
import blackboxprotobuf
search_request = pb.SearchService_SearchRequest()
search_request.InterfaceType = 1
search_request.Commonrequest.SearchType = 'paper'
search_request.Commonrequest.SearchWord = '爬虫'
search_request.Commonrequest.CurrentPage = 2
search_request.Commonrequest.PageSize = 20
search_request.Commonrequest.SearchFilterList.append(0)
form_data = search_request.SerializeToString()
# with open('me1.bin', mode="wb") as f:
# f.write(form_data)
# print(SearchRequest.SerializeToString().decode())
bytes_head = bytes()
# 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",
"Origin": "https://**********",
"Referer": "https://**********/paper",
"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)
# deserialize_data, message_type = blackboxprotobuf.decode_message(response.content)
# data, message_type = blackboxprotobuf.decode_message(response.content)
# print(data)
# print(message_type)
search_response = pb.SearchService_SearchResponse()
search_response.ParseFromString(response.content)
print(search_response)
```
# 七、注意:
其实这段代码还有点问题,就是最后取的`response.content`的真实长度是多少,从第6位开始,一共有多长呢,我们只是刚刚好取完是正确的。我们在从fiddler的抓包分析,刚刚不是前面还有5位16进制数:`00 00 00 93 E7`吗?我们在网站解析一下
刚刚好就是我们断点时的
所以前5位十六进制代表的是我们需要的响应数据的长度。
处理方法:
```python
data_len=int.from_bytes(response.content[:5], 'big') #bytes转int
search_response = pb.SearchService_SearchResponse()
search_response.ParseFromString(response.content)
```
希望我们的文章能让你们对protobuf类型网站的逆向有更深的理解,某音直播弹幕其实也是类似原理,抓住这节和上节说的关键词,相信大家都能解决。
**`deserializeBinary------deserializeBinaryFromReader`**(**重点核心**)
**`serializeBinary------serializeBinaryFromReader`**(**重点核心**)
有说的不够好的地方欢迎指出,希望你看了有所收获!感谢!
如果想我多出文章,就多点赞转发,谢谢啦,写一篇真的好累,好久啊........
# 八、参考文章
https://blog.csdn.net/qq_35491275/article/details/111721639
https://mp.weixin.qq.com/s/DzCz66_Szc7vfG6bpl956w
https://bbs.kanxue.com/thread-265537.htm 本帖最后由 scAnn 于 2023-7-22 06:10 编辑
感谢分享,我的目标网站是grpc-web-text协议的,多了base64处理,base64的不能直接解析protobuf,看压缩的代码也逆向出来了proto,用@protobuf-ts/grpcweb-transport成功调用接口函数,没有去手动解析base64。
bytes_head = bytes()
试了下按这个规则手动转码base64,转码出来的数据和grpc-web-text的一样,可以使用普通http请求发送数据了。
// 将 Protocol Buffers 数据 Uint8Array 放入 Buffer
const encodedData = Buffer.from(loginRequest.serializeBinary())
console.log(encodedData)
// 创建一个包含5个零字节的 Buffer
const zeroBuffer = Buffer.alloc(5)
// 第5位写入Uint8Array数据长度
zeroBuffer.writeInt32BE(loginRequest.serializeBinary().length, 1)
// 将 Buffer 和 loginRequest 的二进制数据拼接在一起
const combinedBuffer = Buffer.concat()
console.log(combinedBuffer.toString('base64')) 之前爬这个网站因为protobuf 数据格式搞得头疼最后还是选择用selenium爬了 {:1_907:}好像和我那个H5游戏的二进制wss传输数据是差不多的 sweetarea 发表于 2024-4-18 15:30
大佬,方法一:直接使用第三方库,我按文章中的代码 但是 在
data, message_type = blackboxprotobuf.deco ...
这个库解析不了数据量大的返回 向大佬学习:loveliness: 向大佬学习 emmm排版有点问题,给你编辑了一下(结果因为一大堆外链图片把自己ip搞封了=_=)
但是似乎还是有点问题?不过问题不大,你反正再改一下吧,吾爱的话,最好一级标题作为各段落的标题 这都是大神的牛逼经验,作品用心了。感谢分享。 大佬太强大了,争取哪天成为大佬啊😂 "一个个分析按顺序写proto文件" 从这里往后那段proto代码文件就没看懂,不知道咋写出来的了 学习了,多谢分享 厉害了,跟着研究下 大佬厉害了,学习