經過JS逆向ProtoBuf 反反爬思路分享

前言

本文意在記錄,在爬蟲過程當中,我首次遇到Protobuf時的一系列問題和解決問題的思路。html

文章編寫遵循當時工做的思路,優勢:很是詳細,缺點:文字冗長,描述不許確前端

protobuf用在先後端傳輸,在必定程度節約了帶寬,也爲爬蟲工程師增長了工做量。python

碰見Protobuf

一拿到網站,F12查看是否有相關數據的請求接口git

ok! 接口找到了,看下請求參數吧!github

image-20201220094448661

emmm~~ 爲啥請求參數是亂碼?web

img

平時見着的都是這個樣子滴?能夠直接看到參數!編程

image-20201220095032422

哎,我們這初出茅廬的菜鳥,乖乖搜搜,看看有沒有前輩們寫過相關的文章json

搜索了 接口請求參數亂碼爬蟲請求參數亂碼 等關鍵詞,沒有相關的答案(後面瞭解後,才知道這種關鍵詞匹配不到Protobuf很正常)後端

好吧,沒有現成的答案,因而乖乖的分析請求頭數組

image-20201220095713716

咦~ 這個類型重來沒見過啊!老實說我只見過如下幾種:

  • application/json: JSON數據格式
  • application/octet-stream : 二進制流數據
  • application/x-www-form-urlencoded :
    中默認的encType,form表單數據被編碼爲key/value格式發送到服務器(表單默認的提交數據的格式)
  • multipart/form-data : 須要在表單中進行文件上傳時,就須要使用該格式

HTTP Content-Type參考

複製它,搜一搜!嘿,找到了一篇文章,哈哈哈,有救了有救了(心中狂喜)

image-20201220100357525

8409f72d6917919e

原文連接:https://zhuanlan.zhihu.com/p/146083543?utm_source=wechat_session

看了文章以後,仍是很懵逼,圖片超級模糊看不清,不過該做者提供了思路與概念

什麼是gRPC? 什麼是protobuf(Protocol Buffers)?

參考文章:https://blog.csdn.net/dideng7039/article/details/101869819?utm_medium=distribute.pc_relevant_t0.none-task-blog-BlogCommendFromMachineLearnPai2-1.control&depth_1-utm_source=distribute.pc_relevant_t0.none-task-blog-BlogCommendFromMachineLearnPai2-1.control

Protocol Buffers 是一種輕便高效的結構化數據存儲格式,能夠用於結構化數據串行化,或者說序列化。它很適合作數據存儲或 RPC 數據交換格式。可用於通信協議、數據存儲等領域的語言無關、平臺無關、可擴展的序列化結構數據格式。

一張圖草草過掉。。。

img

如何使用protocol buffers?

我已經大概瞭解了這個 protobuf,那麼正常的應該如何去使用呢?

因而乎,又搜索 python protobuf使用教程,好傢伙,絕大部分文章使用教程都是抄谷歌官網的文檔,用例都不帶改變一下的。

不過也不是沒有收穫,搜索過程當中,更加具體的瞭解了protobuf及使用流程,大體以下:

image-20201220103446341

開發者須要先編寫proto文件,在proto文件中編寫預期的數據類型、數據字段、默認值等

而後,經過編譯器生成,編程語言對應的開發包!開發時調開發包中的對應方法進行序列化和反序列化。

思路,有了

871d3db3947846ee

那麼,我要請求這個接口,參數必須得是序列化的字節序列

而要實現序列化,就必需要有開發包,但是開發包是js

而開發包也是編譯而來的,因而只要「拿」到proto文件就能夠編譯任意編程語言的開發包了!

好吧,思路有了,經過js反編譯出proto文件,再編譯爲python包便可!

img

好傢伙,就這樣對待萌新嘛,有點懼怕啊!

反編譯在路上

使用protobuf

這裏寫文章,我就把這一步放前面來,我實際是先調試JS(盲目調),根本不知道找什麼,費事又費力!

如今,我的推薦的步驟是寫一個簡單的proto文件,編譯成JS包,瞧瞧裏面的代碼是什麼樣子的,內心好一個底!

首先咱們須要下載用於編譯的編譯器

https://github.com/protocolbuffers/protobuf/releases/

下載後放在磁盤某個地方,複製路徑,設置環境變量,方便隨時編譯

image-20201220111438636

如今寫一個簡單的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

字符串和整數型一看就明瞭,不作過多解釋,下面瞭解MessageEnum

Message是什麼數據類型?

簡單的理解,能夠把message看做是一個類,在其中定義的變量就是類屬性

在序號爲3的判斷中有這樣一行代碼

proto.School.Community.serializeBinaryToWriter

再來看看School

proto.School.serializeBinaryToWriter

到這裏可知,Community定義在School裏面且類型是Message

在定義序號爲3的數據時,數據類型就是Community,而且是可重複的!

因此纔會出現這樣一個方法writeRepeatedMessage,而且嚴格來講,序號爲3的數據是自定義的Message數據類型,且是可重複的

什麼是可重複?

namecommmunity對比一下,學校名只能有一個吧(別名除外),因此當name設置了值以後,再進行設置值就會覆蓋原來的值!

Message類型的communityrepeated修飾,即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

image-20201220105152143

這個有啥用?當調試工具,檢查到有這個連接的請求即將被髮送時,會自動進入斷點調試狀態

接着,請求一下接口!

image-20201220105440561

Call Stack就是調用棧,這裏就看到了 SearchService字樣的方法,點進去瞧瞧看!

image-20201220125156306

看下這些方法的命名,序列化(serialize)、反序列化(deserialize),基本判定就在這個js文件裏,可是這個js有幾萬行代碼,不可能仔細去看也不必。

在這裏手動打個斷點,而後從新請求一次

image-20201220125448793

而後,耐心的慢慢的調試下去,看,這個方法名,這種命名方式,眼熟不?

image-20201220130026633

在這裏,直接就能夠看出其基本結構

message SearchService {
    message SearchRequest {	
    }
}

而後,咱們繼續調試。

image-20201220130354276

這裏能夠看出SearchRequest定義了兩個變量,分別是序號爲1message類型的CommonRequest和序號爲2enum類型的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

image-20201220131546581

這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;
	}

SearchSortSecond都是在SearchService定義的,Ctrl + F搜索

SearchService.SearchSort.serializeBinaryToWriter

image-20201220132716911

SearchService.Second.serializeBinaryToWriter

image-20201220132734602

顯而易見,這兩個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

下載地址:https://pc.qq.com/detail/10/detail_3330.html

以前審查工具抓包已經看到了,請求參數是亂碼,還抓包?

此次抓包會使用到fiddler默認的hexview插件,雖然如今是亂碼,不過仍是有辦法的!

這些黑色樣式的十六進制編碼就是須要的數據!

image-20201220135145415

選中,右鍵保存爲字節文件

image-20201220135304849

這個字節數據是能夠經過protoc編譯器解碼出來的哦!

來,試試看!

image-20201220135912976

解碼失敗了,在本例中,這裏傳輸的數據不只僅只有請求參數,他的頭部還有一段校驗和

就以下圖中的 00 00 00 00 4F,這段校驗和是不屬於數據序列化後的字節,是後來加上去的!

這種狀況,依然是能夠經過js調試分析得出結論!

image-20201220140040254

那麼去掉校驗和的字節序列就是編碼後的數據,而解碼以後源數據就是這個樣子的!

image-20201220140148650

與以前編寫的proto文件,對比看看

image-20201220141635415

實際傳輸時,簡單的看,鍵就是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須要設置兩個值,一個是commonrequestinterfaceType

commonrequestCommonRequest類型,它有好幾個字段,例如能夠這樣寫:

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())

至此,請求參數的序列化已是完成了!

image-20201220200906007

請求接口

這裏只需注意一點就是請求頭裏的內容編碼 '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都不相同,具體狀況具體分析!

成功拿到數據,這裏由於編碼的緣由依然是亂碼,因此這裏就得反序列化!

image-20201220210259412

解碼響應數據

基本思路是一致的:逆向JS -> 編寫proto文件 -> 編譯爲Python包 -> 調用包實現反序列化數據

本來我想,搞定了請求,響應豈不是同樣的!

而後果真吃癟了!

你它孃的我他媽直接口吐芬芳 鬼火表情包_口吐_芬芳_鬼火_直接表情

問題:

  • 找不到接收響應的代碼片斷
  • 在JS編譯包內分析的數據結構不完整

聽我娓娓道來

我在請求發送以後,一直單步調試,耗費了很長時間,就是找不到響應的內容

當我看到getResponseMessage()方法時,我以爲我看到了但願,而後調試進入

image-20201223221505046

在調用棧內看到了熟悉的字眼 SearchService

image-20201225192429634

SearchService內包含SearchResponse,而後一路調試下去(和以前的步驟同樣),逆向出響應的proto文件!

按正常的流程,就是用編譯包反序列化數據便可,可是這裏又遇到了問題!

分析hexview

使用以前的操做:經過查看hexview,選取數據段部分,用proto編譯工具反解碼

image-20201225210425456

我猜想和以前請求時的操做同樣,前5位是記錄數據段長度的字節序列頭,因此我就認爲從第6位開始到最後的這一部分就是數據段,也就是咱們須要解碼的部分!

可是,當我把這部分保存爲二進制文件,用proto編譯工具反解碼時,一直提示解析失敗!

這個就讓我很懷疑是否是猜想錯了,而後我又想若是又字節序列頭,那會不會又字節序列尾呢?

接着,我查看了響應頭信息,總長度爲 20125

image-20201225211451992

又接着將前5位十六進制轉爲十進制,獲得數據段長度爲 20100

image-20201225211600079

20125 - 20100 = 25

去掉頭部的5位,那麼也就是說尾部是20位,至此通過個人」掐頭去尾「,就這樣獲得了數據段!

數據對照

image-20201225220904624

如今就是按解析出來的數據序號對應的數據類型與我編寫的proto文件進行一一對照,看下數據類型是否符合。

而後,我遇到了兩個問題:

一、同一數據字段,數據類型不一致

image-20201225221112281

上圖紅框圈出來的部分,正常的話應該序號爲8的字段是一個message類型

在這個消息類型內部呢,包含一個可重複(repeated)的字符串類型(string)的字段

然而,中間竟然在字符串之間插了一個message?

由於才接觸grpc,我還覺得這樣是容許存在的,畢竟編譯器正確解析出來。搜了一圈,也沒找到同一字段容許多種數據類型

我根據這個字段對應的 0x726f6a61,在hexview中查找

image-20201225221750067

很明顯,這應該是一個單詞,而編譯器解析時出錯了!

二、proto文件編寫得不完整

我逆向出了SearchService.SearchResponse,響應回來時反序列化爲一個長度爲4的數組,其中第2位沒有值。

image-20201226094124794

與解碼出來的數據序號是一致的。

image-20201226094810049

然而如今的問題是,多一個序號爲1002的數據字段

image-20201226094930056

響應傳回的數據結構就是這樣的:

1:
3:
4:
1002:

而我本身構造的proto文件中的數據結構則是這樣的:

1:
2: // 根據實際需求,可省略不寫
3:
4:

也就是說,沒有序號爲1002的這個數據字段!

我在這裏調試了好久,就是沒有找到 字節流轉成JS數組的方法!

而後,只能根據編譯工具解碼的1002序號的數據樣式,繼續反寫proto文件。

雖然不知道字段名,可是不影響,在上面也瞭解過,其核心是知道數據類型是什麼便可!

編譯工具解碼後的逆向規則小結

數字,根據狀況而定,通常是int32

" " 字符串類型

{ } message類型

出現多個重複序號,此字段可重複,即被repeated修飾

總結

  • 瞭解了grpc,protocol buffers這種從未接觸過的傳輸方式
  • 對瀏覽器的審查工具的使用也更加熟練了
  • 掌握了一點JS逆向的思路

參考文章

https://blog.csdn.net/dideng7039/article/details/101869819?utm_medium=distribute.pc_relevant_t0.none-task-blog-BlogCommendFromMachineLearnPai2-1.control&depth_1-utm_source=distribute.pc_relevant_t0.none-task-blog-BlogCommendFromMachineLearnPai2-1.control

https://www.yuanrenxue.com/app-crawl/parse-protobuf.html

https://zhuanlan.zhihu.com/p/146083543?utm_source=wechat_session

相關文章
相關標籤/搜索