前言
本文意在記錄,在爬蟲過程中,我首次遇到Protobuf時的一系列問題和解決問題的思路。
文章編寫遵循當時工作的思路,優點:非常詳細,缺點:文字冗長,描述不准確
protobuf
用在前后端傳輸,在一定程度節約了帶寬,也為爬蟲工程師增加了工作量。
遇見Protobuf
一拿到網站,F12查看是否有相關數據的請求接口
ok! 接口找到了,看下請求參數吧!
emmm~~ 為啥請求參數是亂碼?
平時見着的都是這個樣子滴?可以直接看到參數!
哎,咱們這初出茅廬的菜鳥,乖乖搜搜,看看有沒有前輩們寫過相關的文章
搜索了 接口請求參數亂碼 、爬蟲請求參數亂碼 等關鍵詞,沒有相關的答案(后面了解后,才知道這種關鍵詞匹配不到Protobuf很正常)
好吧,沒有現成的答案,於是乖乖的分析請求頭
咦~ 這個類型重來沒見過啊!老實說我只見過以下幾種:
- application/json: JSON數據格式
- application/octet-stream : 二進制流數據
- application/x-www-form-urlencoded :
- multipart/form-data : 需要在表單中進行文件上傳時,就需要使用該格式
復制它,搜一搜!嘿,找到了一篇文章,哈哈哈,有救了有救了(心中狂喜)
原文鏈接:https://zhuanlan.zhihu.com/p/146083543?utm_source=wechat_session
看了文章之后,還是很懵逼,圖片超級模糊看不清,不過該作者提供了思路與概念
什么是gRPC? 什么是protobuf(Protocol Buffers)?
Protocol Buffers 是一種輕便高效的結構化數據存儲格式,可以用於結構化數據串行化,或者說序列化。它很適合做數據存儲或 RPC 數據交換格式。可用於通訊協議、數據存儲等領域的語言無關、平台無關、可擴展的序列化結構數據格式。
一張圖草草過掉。。。
如何使用protocol buffers?
我已經大概了解了這個 protobuf,那么正常的應該如何去使用呢?
於是乎,又搜索 python protobuf使用教程,好家伙,絕大部分文章使用教程都是抄谷歌官網的文檔,用例都不帶改變一下的。
不過也不是沒有收獲,搜索過程中,更加具體的了解了protobuf及使用流程,大致如下:
開發者需要先編寫proto文件,在proto文件中編寫預期的數據類型、數據字段、默認值等
然后,通過編譯器生成,編程語言對應的開發包!開發時調開發包中的對應方法進行序列化和反序列化。
思路,有了
那么,我要請求這個接口,參數必須得是序列化的字節序列
而要實現序列化,就必須要有開發包,可是開發包是js
的
而開發包也是編譯而來的,於是只要“拿”到proto
文件就可以編譯任意編程語言的開發包了!
好吧,思路有了,通過js
反編譯出proto
文件,再編譯為python
包即可!
好家伙,就這樣對待萌新嘛,有點害怕啊!
反編譯在路上
使用protobuf
這里寫文章,我就把這一步放前面來,我實際是先調試JS(盲目調),根本不知道找什么,費事又費力!
現在,個人推薦的步驟是寫一個簡單的proto
文件,編譯成JS包,瞧瞧里面的代碼是什么樣子的,心里好一個底!
首先我們需要下載用於編譯的編譯器
https://github.com/protocolbuffers/protobuf/releases/
下載后放在磁盤某個地方,復制路徑,設置環境變量,方便隨時編譯
現在寫一個簡單的proto文件
test.proto
syntax = "proto3"; // 定義proto的版本
message School {
string name = 1; // 學校名
int32 years = 2; // 學校年齡
message Community {
string name = 1; // 社團名稱
enum Grade {
DEFAULT = 0;
THREEGRADE = 3; // 三個年級
SIXGRADE = 6; // 六個年級
}
repeated Community community = 3;
Grade grade = 4;
}
編譯為JS包
❯ protoc --js_out=. .\test.proto
主要還是得自己動手,編譯后,細心觀察,這里截取一段比較重要的代碼
/**
* Serializes the given message to binary data (in protobuf wire
* format), writing to the given BinaryWriter.
* @param {!proto.School} message
* @param {!jspb.BinaryWriter} writer
* @suppress {unusedLocalVariables} f is only used for nested messages
*/
proto.School.serializeBinaryToWriter = function(message, writer) {
var f = undefined;
f = message.getName();
if (f.length > 0) {
writer.writeString(
1,
f
);
}
f = message.getYears();
if (f !== 0) {
writer.writeInt32(
2,
f
);
}
f = message.getCommunityList();
if (f.length > 0) {
writer.writeRepeatedMessage(
3,
f,
proto.School.Community.serializeBinaryToWriter
);
}
f = message.getGrade();
if (f !== 0.0) {
writer.writeEnum(
4,
f
);
}
};
這一段序列化的代碼中出現了如下的方法名:
getName, writeString
getYears, writeInt32
getCommunityList, writeRepeatedMessage
getGrade, writeEnum
然后這一整個判斷,這意味 School中定義了四個數據變量, 序號為1, 2,3,4,而數據類型和變量名可以根據其調用的方法推出:
序號為1的數據類型為String,變量名為name
序號為2的數據類型為Int32,變量名為years
序號為3的數據類型為Message,變量名為community,Repeated下面講
序號為4的數據類型為Enum, 變量名為grade
字符串和整數型一看就明了,不做過多解釋,下面了解Message
和Enum
Message
是什么數據類型?
簡單的理解,可以把message看作是一個類,在其中定義的變量就是類屬性
在序號為3的判斷中有這樣一行代碼
proto.School.Community.serializeBinaryToWriter
再來看看School
的
proto.School.serializeBinaryToWriter
到這里可知,Community
定義在School
里面且類型是Message
在定義序號為3的數據時,數據類型就是Community
,並且是可重復的!
所以才會出現這樣一個方法writeRepeatedMessage
,並且嚴格來說,序號為3的數據是自定義的Message數據類型,且是可重復的
什么是可重復?
用name
和commmunity
對比一下,學校名只能有一個吧(別名除外),所以當name設置了值之后,再進行設置值就會覆蓋原來的值!
而Message
類型的community
被repeated
修飾,即community
是一個包含多個Commounity
實例的數組
沒明白什么意思?沒關系,用Python代碼來解釋一下,就會秒懂!
class Community():
name = ""
class School():
# community_list = [Community(), Community(), Community(), ...]
community = [Community(), Community(), Community(), ...] # 可重復
Enum是什么數據類型?
枚舉類型,
例如,有的學校是初高中一起的,就是6個年級,而有些只有高中或初中就是3個年級。在這兩種限定情況下,只可能出現3或者6,這樣就可以設置枚舉類型,要么3要么6,自己選一個!
這就好比前端中的單選框,必須且只能選擇一個
注意:枚舉類型。必須要有為0的默認選項
總而言之呢,看見writeEnum
就知道這個數據為Enum
類型
repeated
也可以修飾Enum
,其對應的JS寫操作的方法為writePackedEnum
被repeated
修飾的enum
類型,則好似前端中的多選框,至少選擇一個,可選擇多個
小結一下:
被repeated
修飾的message
類型的數據,看作是一個包含任意個某message
類型數據的數組
被repeated
修飾的enum
類型的數據,看作是一個包含任意個整數類型數據的整型數組
調試JS反寫proto
知道了proto
文件編譯后js
序列化的核心代碼之后,那么接下來斷點調試,就不至於無頭蒼蠅亂撞!
將接口的請求地址復制,粘貼至審查工具 -> Sources -> XHR/fetch Breakpoints
這個有啥用?當調試工具,檢查到有這個鏈接的請求即將被發送時,會自動進入斷點調試狀態。
接着,請求一下接口!
Call Stack就是調用棧,這里就看到了 SearchService
字樣的方法,點進去瞧瞧看!
看下這些方法的命名,序列化(serialize)、反序列化(deserialize),基本斷定就在這個js文件里,但是這個js有幾萬行代碼,不可能仔細去看也沒必要。
在這里手動打個斷點,然后重新請求一次
然后,耐心的慢慢的調試下去,看,這個方法名,這種命名方式,眼熟不?
在這里,直接就可以看出其基本結構
message SearchService {
message SearchRequest {
}
}
然后,我們繼續調試。
這里可以看出SearchRequest
定義了兩個變量,分別是序號為1
的message
類型的CommonRequest
和序號為2
的enum
類型的InterfaceType
。
根據SearchService.CommonRequest
可知,CommonRequest
定義在SearchService
中
所以,proto文件現在是這樣的:
message SearchService {
message CommonRequest {
}
enum InterfaceType {
// 定義了什么不知道,但是enum必須有一個值就是0
DEFAUTL = 0;
}
message SearchRequest {
CommonRequest commonrequest = 1; // 任意變量名
InterfaceType interfaceType = 2; // 任意變量名
}
}
關於變量名是什么,這個其實不重要,后面會講到
繼續往下調試,進入到了CommonRequest
這if判斷,這方法名,熟悉嘛?
根據方法名,直接就可以反寫出CommonRequest
message SearchSort {
}
message Second {
}
enum SearchScope {
A = 0;
}
enum SearchFilter {
B = 0;
}
message CommonRequest {
string searchType = 1;
string searchWord = 2;
SearchSort searchSort = 3;
repeated Second seconds = 4;
int32 currentPage = 5;
int32 pageSize = 6;
SearchScope searchScope = 7
repeated SearchFilter searchFilter = 8;
bool languageExpand = 9;
bool topicExpand = 10;
}
SearchSort
和Second
都是在SearchService
定義的,Ctrl + F
搜索
SearchService.SearchSort.serializeBinaryToWriter
SearchService.Second.serializeBinaryToWriter
顯而易見,這兩個message如下:
enum Order {
C = 0;
}
message SearchSort {
string field = 1;
Order order = 2;
}
message Second {
string field = 1;
string value = 2;
}
對於所有的enum枚舉類,至少填充一個默認值0,且變量名唯一
有的情況,枚舉類含有哪些字段,可以在代碼中直接看到,就照抄寫進去。
看不到的,給個唯一變量名,默認值為0即可
好了,對於這一個請求接口的proto文件就算反寫完成了!
syntax = "proto3";
message SearchService {
enum Order {
C = 0;
}
enum SearchScope {
A = 0;
}
enum SearchFilter {
B = 0;
}
message SearchSort {
string field = 1;
Order order = 2;
}
message Second {
string field = 1;
string value = 2;
}
message CommonRequest {
string searchType = 1;
string searchWord = 2;
SearchSort searchSort = 3;
repeated Second seconds = 4;
int32 currentPage = 5;
int32 pageSize = 6;
SearchScope searchScope = 7;
repeated SearchFilter searchFilter = 8;
bool languageExpand = 9;
bool topicExpand = 10;
}
enum InterfaceType {
// 定義了什么不知道,但是enum必須有一個值就是0
DEFAUTL = 0;
}
message SearchRequest {
CommonRequest commonrequest = 1; // 任意變量名
InterfaceType interfaceType = 2; // 任意變量名
}
}
現在還差一個源數據,即我們需要知道待編譯的源數據是什么樣子的?
抓包!
確定請求參數
抓包工具:fiddler4
之前審查工具抓包已經看到了,請求參數是亂碼,還抓包?
這次抓包會使用到fiddler默認的hexview
插件,雖然現在是亂碼,不過還是有辦法的!
這些黑色樣式的十六進制編碼就是需要的數據!
選中,右鍵保存為字節文件
這個字節數據是可以通過protoc編譯器解碼出來的哦!
來,試試看!
解碼失敗了,在本例中,這里傳輸的數據不僅僅只有請求參數,他的頭部還有一段校驗和
就如下圖中的 00 00 00 00 4F
,這段校驗和是不屬於數據序列化后的字節,是后來加上去的!
這種情況,依然是可以通過js調試分析得出結論!
那么去掉校驗和的字節序列就是編碼后的數據,而解碼之后源數據就是這個樣子的!
與之前編寫的proto文件,對比看看
實際傳輸時,簡單的看,鍵就是proto中定義的序號,這就是之前提到的 變量名是什么根本不重要,變量名只是方便開發者開發時便於理解與調用。(傳輸一個數字遠比傳輸一個字符串更有效率)
而對於,我們爬蟲開發者而言,構造出這個請求參數,獲取這個接口的響應內容是最終目標!
完全還原proto文件是不需要的!
實現請求
最后還有幾步
編譯proto為python包,構建參數,序列化參數,發送請求
Python使用編譯包
在網上搜了搜,好像都沒有寫具體怎么使用這個編譯包,基本類型使用簡單,對於repeated修飾的message和enum類型,則在下文說明具體該調用什么方法,該怎么賦值!
protoc --python_out=. ./test.proto
目錄下生成了test_pb2.py
拖入項目中,需要使用時就調用即可
那么,在Python中,具體如何使用編譯好的包呢?
import test_pb2 as pb # 導包
請求參數序列化的是SearchRequest
,所以可以理解為先實例化一個SearchRequest
search_request = pb.SearchService.SearchRequest()
search_request
需要設置兩個值,一個是commonrequest
和interfaceType
commonrequest
是CommonRequest
類型,它有好幾個字段,例如可以這樣寫:
search_request.commonrequest.searchType = "paper"
search_request.commonrequest.searchWord = '學位授予單位:("電子科技大學")'
search_request.commonrequest.currentPage = 2
search_request.commonrequest.pageSize = 20
這些是字符串,數字型的都是直接賦值的,很好理解!
而對於,repeated
修飾的messsage
類型和enum
類型,則需要稍微多幾個步驟
例如:
# 可重復message類型
# 可重復message類型,需要調用一個add方法,然后將對應字段賦值
seconds = search_request.commonrequest.Second.add()
seconds.field = "Type"
seconds.value = '"Thesis"'
# 可重復enum枚舉類型
search_request.commonrequest.searchFilter.append(0)
這里可以看作是一個動態長度的整型數組,append
將新值追加至末尾
參數序列化的完整代碼
import message_pb2 as pb
search_request = pb.SearchService.SearchRequest()
search_request.commonrequest.searchType = "paper"
search_request.commonrequest.searchWord = '學位授予單位:("電子科技大學")'
seconds = search_request.commonrequest.seconds.add()
seconds.field = "Type"
seconds.value = '"Thesis"'
search_request.commonrequest.currentPage = 1
search_request.commonrequest.pageSize = 20
search_request.commonrequest.searchFilter.append(0)
search_request.interfaceType = 2
with open('me.bin', mode="wb") as f:
f.write(search_request.SerializeToString())
print(search_request.SerializeToString().decode())
至此,請求參數的序列化已經是完成了!
請求接口
這里只需注意一點就是請求頭里的內容編碼 'Content-Type': 'application/grpc-web+proto'
代碼
headers = {
'Referer': 'xxxx',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36',
'Content-Type': 'application/grpc-web+proto',
}
bytes_body = search_request.SerializeToString()
# 構造字節序列的頭部
bytes_head = bytes([0, 0, 0, 0, len(bytes_body)])
resp = requests.post(url="xxxx",
data=bytes_head+bytes_body,
headers=headers)
print(resp.content)
在本例中,請求的字節序列有個包含校驗碼的頭部,所以在請求前需要加上去。
各個網站或APP都不相同,具體情況具體分析!
成功拿到數據,這里因為編碼的原因依然是亂碼,所以這里就得反序列化!
解碼響應數據
基本思路是一致的:逆向JS -> 編寫proto文件 -> 編譯為Python包 -> 調用包實現反序列化數據
原本我想,搞定了請求,響應豈不是一樣的!
然后果然吃癟了!
問題:
- 找不到接收響應的代碼片段
- 在JS編譯包內分析的數據結構不完整
聽我娓娓道來
我在請求發送之后,一直單步調試,耗費了很長時間,就是找不到響應的內容
當我看到getResponseMessage()
方法時,我覺得我看到了希望,然后調試進入
在調用棧內看到了熟悉的字眼 SearchService
SearchService內包含SearchResponse,然后一路調試下去(和之前的步驟一樣),逆向出響應的proto
文件!
按正常的流程,就是用編譯包反序列化數據即可,但是這里又遇到了問題!
分析hexview
使用之前的操作:通過查看hexview
,選取數據段部分,用proto編譯工具反解碼
我猜測和之前請求時的操作一樣,前5位是記錄數據段長度的字節序列頭,所以我就認為從第6位開始到最后的這一部分就是數據段,也就是我們需要解碼的部分!
但是,當我把這部分保存為二進制文件,用proto編譯工具反解碼時,一直提示解析失敗!
這個就讓我很懷疑是不是猜測錯了,然后我又想如果又字節序列頭,那會不會又字節序列尾呢?
接着,我查看了響應頭信息,總長度為 20125
又接着將前5位十六進制轉為十進制,得到數據段長度為 20100
20125 - 20100 = 25
去掉頭部的5位,那么也就是說尾部是20位,至此經過我的”掐頭去尾“,就這樣得到了數據段!
數據對照
現在就是按解析出來的數據序號對應的數據類型與我編寫的proto文件進行一一對照,看下數據類型是否符合。
然后,我遇到了兩個問題:
1、同一數據字段,數據類型不一致
上圖紅框圈出來的部分,正常的話應該序號為8的字段是一個message
類型
在這個消息類型內部呢,包含一個可重復(repeated)的字符串類型(string)的字段
然而,中間居然在字符串之間插了一個message?
因為才接觸grpc,我還以為這樣是允許存在的,畢竟編譯器正確解析出來。搜了一圈,也沒找到同一字段允許多種數據類型
我根據這個字段對應的 0x726f6a61,在hexview
中查找
很明顯,這應該是一個單詞,而編譯器解析時出錯了!
2、proto文件編寫得不完整
我逆向出了SearchService.SearchResponse
,響應回來時反序列化為一個長度為4的數組,其中第2位沒有值。
與解碼出來的數據序號是一致的。
然而現在的問題是,多一個序號為1002的數據字段
響應傳回的數據結構就是這樣的:
1:
3:
4:
1002:
而我自己構造的proto文件中的數據結構則是這樣的:
1:
2: // 根據實際需求,可省略不寫
3:
4:
也就是說,沒有序號為1002的這個數據字段!
我在這里調試了很久,就是沒有找到 字節流轉成JS數組的方法!
然后,只能根據編譯工具解碼的1002序號的數據樣式,繼續反寫proto文件。
雖然不知道字段名,但是不影響,在上面也了解過,其核心是知道數據類型是什么即可!
編譯工具解碼后的逆向規則小結
數字,根據情況而定,一般是int32
" "
字符串類型
{ }
message類型
出現多個重復序號,此字段可重復,即被repeated
修飾
總結
- 了解了grpc,protocol buffers這種從未接觸過的傳輸方式
- 對瀏覽器的審查工具的使用也更加熟練了
- 掌握了一點JS逆向的思路
參考文章
https://www.yuanrenxue.com/app-crawl/parse-protobuf.html
https://zhuanlan.zhihu.com/p/146083543?utm_source=wechat_session