本文距离上次更新已过去 0 天,部分内容可能已经过时,请注意甄别。
什么是protobuf 一拿到网站,F12查看是否有相关数据的请求接口 请求体是这样的 请求头的类型也非常见的
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 总结在图下了
那如何使用protobuf? 开发者需要先编写proto文件,在proto文件中编写预期的数据类型、数据字段、默认值等 然后,通过编译器生成,编程语言对应的开发包!开发时调开发包中的对应方法进行序列化和反序列化。 所以请求的时候需要参数是序列化的字节序列,对接收到的返回值进行反序列化 而要实现序列化,就必须要有开发包,可是开发包是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" ; 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
两条语句都可以,第一条会拆分成多个文件,第二条是合并成一个,推荐使用第二条 头部就能看到定义好的几个大的对象 可以大概看下代码,截一段比较重要的
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 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下面讲
字符串和整数型一看就明了,不做过多解释,下面了解Message 和Enum 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跟进去打断点看看 看下这些方法的命名,序列化(serialize)、反序列化(deserialize),基本断定就在这个js文件里,但是这个js有几万行代码,不可能仔细去看也没必要。 看到明显的prototype字样,直接搜proto的特征
toObject 将获取到的数据转成结构化数据 deserializeBinary 二进制数据转换成数组结构(反序列化 | 获取到的数据需要Uint8Array转成二进制) deserializeBinaryFromReader 根据规则,将二进制数据转换成数组结构 serializeBinary 将数据转成二进制(序列化) serializeBinaryToWriter 根据规则,将数据转换成二进制数据(序列化)
可以肯定就是proto了 一步步跟进后,到序列化发包的位置 在这里,直接就可以看出其基本结构
1 2 3 4 message SearchService { message SearchRequest { } }
继续调试。 这里可以看出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" ; message SearchService { message SearchRequest { CommonRequest commonRequest = 1 ; InterfaceType interfaceType = 2 ; } message CommonRequest { } enum InterfaceType { DEFAULT = 0 ; } }
关于变量名是什么,这个其实不重要 继续往下调试,进入到了CommonRequest 根据方法名,直接就可以反写出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 SearchScope { ScopeDefault = 0 ; } enum SearchFilter { FilterDefault = 0 ; } }
SearchSort和Second都是在SearchService定义的,Ctrl + F搜索 SearchService.SearchSort.serializeBinaryToWriter SearchService.Second.serializeBinaryToWriter 补齐字段,请求接口的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" ; 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 SearchScope { ScopeDefault = 0 ; } enum SearchFilter { FilterDefault = 0 ; } }
对于所有的enum枚举类,至少填充一个默认值0,且变量名唯一 有的情况,枚举类含有哪些字段,可以在代码中直接看到,就照抄写进去。 看不到的,给个唯一变量名,默认值为0即可 现在还差一个源数据,即我们需要知道待编译的源数据是什么样子的? 使用fiddler进行抓包查看请求参数 抓到包后查看HexView,黑色部分就是请求体,里面也可以看到我们搜素的关键词python 选中,右键保存为字节文件也就是bin后缀,这里要注意,前5个字节表示请求体的长度,从第6个字节开始到结束刚好就是0x1A字节数据是可以通过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文件进行对比 像有些没包含到的字段,是请求的时候页面没做一些条件筛选,就没触发到某些字段 实际传输时,简单的看,键就是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 search_request.commonRequest.searchFilter.append(0 ) search_request.interfaceType = 1 form_data = search_request.SerializeToString() print (form_data)with open ('me.bin' , mode="wb" ) as f: f.write(form_data) print (search_request.SerializeToString().decode())
至此,请求参数的序列化已经是完成了 发送请求完整代码
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 requestssearch_request = pb.SearchService.SearchRequest() search_request.commonRequest.searchType = 'paper' search_request.commonRequest.searchWord = 'python' search_request.commonRequest.currentPage = 2 search_request.commonRequest.pageSize = 20 search_request.commonRequest.searchFilter.append(0 ) search_request.interfaceType = 1 form_data = search_request.SerializeToString() print (form_data)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得到的数据是一样的乱码如下图 其实这个也是protobuf格式,发过去的是protobuf格式,收到的也是protobuf格式,只是它是以二进制序列化格式传输的,所以看上去像乱码. 接下来会带来两种方法:①直观但有点复杂,②便捷但不太直观
方法一 写对应的响应的proto文件,和发包一样。当然可以和发包写在一起。 老规矩,还是打断点从堆栈进行分析,根据发包的堆栈主要看app开头的js,因为chunk开头的是基本库,很少在里面做手脚,一般都是在自写的js里面做加密或其他操作。 一步步调试后, 异步然后获得了值去.toObject,这个toObject就是proto文件转js的时候会产生的一个api函数接口,可以简单使用protoc去尝试转化成js看看。 这里不好跟进,直接全局搜索一下:proto.SearchService.SearchResponse 这里接受响应后需要把二进制数据进行反序列化,那么就会用到下面的apideserializeBinary——deserializeBinaryFromReader ( 重点核心 ) 完整的就是 proto.SearchService.SearchResponse.deserializeBinaryFromReader 一下子就定位到了,和请求的一样理解,只是他现在变成了case语句来表示序号位置,read后面的类型来表示类型。 序号4有个message,进去查看 这个返回的数据量太大了,标号也特别的多,有没有什么更好的方法得到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" );const template = require ("@babel/template" ).default ;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" }); 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 { 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 { 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代码处理对象无法确定,所以就没加载到。 我们在调试里面,搜索关键词ExportResponse.deserializeBinaryFromReader 跟进去就能找到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 pbimport requestssearch_request = pb.SearchService_SearchRequest() search_request.Commonrequest.SearchType = 'paper' search_request.Commonrequest.SearchWord = 'python' search_request.Commonrequest.CurrentPage = 2 search_request.Commonrequest.PageSize = 20 search_request.Commonrequest.SearchFilterList.append(0 ) search_request.InterfaceType = 1 form_data = search_request.SerializeToString() print (form_data)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) search_response = pb.SearchService_SearchResponse() search_response.ParseFromString(response.content[5 :]) print (search_response)
可以看到很直观,取值也方便。 上面之所以从响应的第六位字节开启取,是跟上面发包一样的,前五个字节表示请求头的长度 下面是proto的核心,序列化和反序列化serializeBinary——serializeBinaryFromReader ( 重点核心 )deserializeBinary——deserializeBinaryFromReader ( 重点核心 )
方法二 使用python应对protobuf的第三方库:blackboxprotobuf 安装命令:pip install blackboxprotobuf 调用核心函数 :blackboxprotobuf.decode_message(Byte类型数据 ),进行解protobuf格式数据 上面是数据对应结构位置,下面是类型对应结构位置 虽然拿到了数据,只是位置序号加内容,我们其实要靠猜才能知道是什么,这种就不需要去写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