背景
因為想拿到一些知乎彈幕的資料 以及做一個直播播報機器人,所以最近在研究知乎直播的彈幕
分析
抓取比較簡單,不多說了...都是正常的操作
但是 拿到的資料卻很奇怪
為了演示方便,我們以 rest 介面示範,本質上和 websocket 介面是一樣的。
我們以直播間 11529 為例子
拿取彈幕的介面是: https://www.zhihu.com/api/v4/drama/theaters/11529/recent-messages
可以看到彈幕資料應該在 messages 裡面,但是資料好像經過了某種加密
js 大搜查
首先全域性搜尋 recent-messages
,找到需要的 js 檔案(這邊也就是查詢哪個 js 請求了拿彈幕的網址)
把 js 檔案下載到本地格式化後,搜尋 recent-messages
搜尋LOAD_RECENT_MESSAGES
找到了如何解析 message 的第一步 base64 解密
js 中 atob 函式解釋[1]
並且 轉換後的結果傳給了函式 p
繼續搜尋p
往上搜尋(記得搜尋模式選擇全詞匹配與區分大小寫) 要不然搜尋結果太多了...
運氣好 上面第一個就是
為了驗證可以替換知乎 js 到你本地的 js
加兩行console.log
就行了
程式碼如下...
function p(e) {
console.log("before:", e);
var t = d.EventMessage.decode(e),
n = t.eventCode,
r = t.event;
console.log("after:", t);
複製程式碼
可以發現這就是我們想要的
那麼現在只要搞清楚 EventMessage.decode
這個方法幹了啥就可以了...
然後搜到了具體的程式碼
就一步一步 debug,發現好像是某種編碼規範?
難道是知乎自己定義的嗎...
在這邊搞了一週...還沒有搞明白
大概說下 我迷惑的點在哪
如上這個 Uint8Array
先是對第一位 >>>
操作,判斷這個 位元組表示的是 什麼含義
然後後面的 xxx 個位元組表示具體的值,但是 xxx 個位元組到底是多少個,是怎麼區分的 我沒有弄明白
特別是如下 這三個明明都是 int64,他們的位元組長度卻不一樣
timestampMs
是 6 個位元組
theaterId
是 2 個位元組
dramaId
是 9 個位元組
我拿個小本本一邊 debug 一邊記...(本來欄位少的話,看多了是可以直接找到規律 這樣解決的,但是其中一個欄位event
是個字典,有 40 個 key...
我看到程式碼的時候 就炸了...
所以我就想 算了 不瞭解它到底怎麼實現的把,我直接吧這段 js 摳出來...然後搭個 nodejs 的服務得了
扣 js
扣的時候 還計較簡單,除了這一句的s
e instanceof s || (e = s.create(e));
複製程式碼
這個 s 到這我就找不到它到底從哪來的了
所以我就只能 google.(搜了好多次)
真是驚喜!發現竟然是protobuf[2]
所以這個所謂的加密 是一種通用的協議...
至此,問題就簡單了
Protocol Buffers
官方的定義如下:
Protocol buffers 是一種與語言無關、與平臺無關的可擴充套件機制,用於序列化結構化資料。
更多介紹可以去看protocol-buffers 官網[3]
下面的內容來自 Burpsuite 中 protobuf 資料流的解析[4]
Varint 編碼
Protobuf 的二進位制使用 Varint 編碼。Varint 是一種緊湊的表示數字的方法。它用一個或多個位元組來表示一個數字,值越小的數字使用越少的位元組數。這能減少用來表示數字的位元組數。
Varint 中的每個 byte 的最高位 bit 有特殊的含義,如果該位為 1,表示後續的 byte 也是該數字的一部分,如果該位為 0,則結束。其他的 7 個 bit 都用來表示數字。因此小於 128 的數字都可以用一個 byte 表示。大於 128 的數字,比如 300,會用兩個位元組來表示:1010 1100 0000 0010。
下圖演示了 protobuf 如何解析兩個 bytes。注意到最終計算前將兩個 byte 的位置相互交換過一次,這是因為 protobuf 位元組序採用 little-endian 的方式。
所以我們上面那個疑惑解決了... 就是怎麼確定某個欄位到底應該幾個位元組(或者說現在能劃分資料了)
數值型別
Protobuf 經序列化後以二進位制資料流形式儲存,這個資料流是一系列 key-Value 對。Key 用來標識具體的 Field,在解包的時候,Protobuf 根據 Key 就可以知道相應的 Value 應該對應於訊息中的哪一個 Field。
Key 的定義如下:
(field_number << 3) | wire_type
Key 由兩部分組成。第一部分是 field_number,比如訊息 tutorial .Person 中 field name 的 field_number 為 1。第二部分為 wire_type。表示 Value 的傳輸型別。Wire Type 可能的型別如下表所示:
Type | Meaning | Used For |
---|---|---|
0 | Varint | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
1 | 64-bit | fixed64, sfixed64, double |
2 | Length-delimi | string, bytes, embedded messages, packed repeated fields |
3 | Start group | Groups (deprecated) |
4 | End group | Groups (deprecated) |
5 | 32-bit | fixed32, sfixed32, float |
以資料流:08 96 01 為例分析計算 key-value 的值:
#!bash
08 = 0000 1000b
=> 000 1000b(去掉最高位)
=> field_num = 0001b(中間4位), type = 000(後3位)
=> field_num = 1, type = 0(即Varint)
96 01 = 1001 0110 0000 0001b
=> 001 0110 0000 0001b(去掉最高位)
=> 1 001 0110b(因為是little-endian)
=> 128+16+4+2=150
複製程式碼
最後得到的結構化資料為:
1:150
其中 1 表示為 field_num,150 為 value。
手動反序列化
以上面例子中序列化後的二進位制資料流進行反序列化分析:
#!bash
0A = 0000 1010b => field_num=1, type=2;
2E = 0010 1110b => value=46;
0A = 0000 1010b => field_num=1, type=2;
07 = 0000 0111b => value=7;
複製程式碼
讀取 7 個字元“Vincent”;
#!bash
10 = 0001 0000 => field_num=2, type=0;
09 = 0000 1001 => value=9;
1A = 0001 1010 => field_num=3, type=2;
10 = 0001 0000 => value=16;
複製程式碼
讀取 10 個字元“Vincent@test.com”;
#!bash
22 = 0010 0010 => field_num=4, type=2;
0F = 0000 1111 => value=15;
0A = 0000 1010 => field_num=1, type=2;
0B = 0000 1011 => value=11;
複製程式碼
讀取 11 個字元“15011111111”;
#!bash
10 = 0001 0000 => field_num=2, type=0;
02 = 0000 0010 => value=2;
複製程式碼
最後得到的結構化資料為:
#!bash
1 {
1: "Vincent"
2: 9
3: "Vincent@test.com"
4 {
1: "15011111111"
2: 2
}
}
複製程式碼
使用 protoc 反序列化
實現操作經常碰到較複雜、較長的流資料,手動分析確實麻煩,好在 protoc 加“decode_raw”引數可以解流資料,我實現了一個 python 指令碼供使用:
import subprocess, sys
import json
import base64
def decode(data):
process = subprocess.Popen(
["protoc", "--decode_raw"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
output = error = None
try:
output, error = process.communicate(data)
except OSError:
pass
finally:
if process.poll() != 0:
process.wait()
return output
with open(sys.argv[1], "rb") as f:
data = f.read()
print('',decode(data))
複製程式碼
回到知乎直播
那麼就先測試解析一條吧
import subprocess, sys
import json
import base64
def decode(data):
process = subprocess.Popen(
["protoc", "--decode_raw"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
output = error = None
try:
output, error = process.communicate(data)
except OSError:
pass
finally:
if process.poll() != 0:
process.wait()
return output
a1 = "CAESpgMKowMKhgMKIDQwYjQ3Y2NiZmM0NDc1YjAxOGE1YTQxN2UxY2Y5ODk3EhLlsI/pgI/mmI7niLHlvrfljY4aDGR1LXlhby0xMy04NiJBaHR0cHM6Ly9waWM0LnpoaW1nLmNvbS92Mi1kMDYxNjFiMWQzOWNkNjRlYmRhNDBmOWMwNjVhNmNhNV94cy5qcGcqswEIiVoSCemFkueqneeqnRgBIAAoJTABOpoBCAEQABpAaHR0cHM6Ly9waWMxLnpoaW1nLmNvbS92Mi0xZDEyNTg1YzdhOTY2MTNkM2JlZjQxMTcyY2Q4ZWYxNV9yLnBuZyJAaHR0cHM6Ly9waWM0LnpoaW1nLmNvbS92Mi1iMDM1ZWRkNTA3NjgwNzU3MmJkNGU3YTg5MjRjZTEzYl9yLnBuZyoHIzcyQkJGRioHIzAwODRGRjJHCAkQjGAaQGh0dHBzOi8vcGljNC56aGltZy5jb20vdjItODI1NTRlYzgzYmViMzJlOWVjNDQxNGY0YzYyMmFjMmNfci5wbmcQARoM5oiR5Lmf6KeJ5b6XIICA8cTaz6SEERiplO2Pki4giVoogKDrtNOUlYQRMhUxLTEyMjczOTE5NjY4NTU4Mzk3NDQ4AQ=="
message = base64.b64decode(a1)
print(decode(message))
複製程式碼
結果如下
Out[7]: b'1: 1\n2 {\n 1 {\n 1 {\n 1: "40b47ccbfc4475b018a5a417e1cf9897"\n 2: "\\345\\260\\217\\351\\200\\217\\346\\230\\216\\347\\210\\261\\345
\\276\\267\\345\\215\\216"\n 3: "du-yao-13-86"\n 4: "https://pic4.zhimg.com/v2-d06161b1d39cd64ebda40f9c065a6ca5_xs.jpg"\n 5 {\n 1: 1152
9\n 2: "\\351\\205\\222\\347\\252\\235\\347\\252\\235"\n 3: 1\n 4: 0\n 5: 37\n 6: 1\n 7 {\n 1: 1\n
2: 0\n 3: "https://pic1.zhimg.com/v2-1d12585c7a96613d3bef41172cd8ef15_r.png"\n 4: "https://pic4.zhimg.com/v2-b035edd5076807572bd4e7a8924c
e13b_r.png"\n 5: "#72BBFF"\n 5: "#0084FF"\n }\n }\n 6 {\n 1: 9\n 2: 12300\n 3: "https://pic4.zhimg.com/v2-82554ec83beb32e9ec4414f4c622ac2c_r.png"\n }\n }\n 2: 1\n 3: "\\346\\210\\221\\344\\271\\237\\350\\247\\211\\345\\276\\227"\n 4: 1227391966855839744\n }\n}\n3: 1585413048873\n4: 11529\n5: 1227323967020912640\n6: "1-1227391966855839744"\n7: 1\n'
複製程式碼
可以看到解析成功了,那麼後面的工作就比較簡單了...
只要按照知乎的 js 對應出某個位置的具體欄位名字就好了...
最後看一個成功的截圖
參考資料
js中atob函式解釋: https://developer.mozilla.org/zh-CN/docs/Web/API/WindowBase64/atob
[2]protobuf: https://github.com/protobufjs/protobuf.js
[3]protocol-buffers官網: https://developers.google.com/protocol-buffers
[4]Burpsuite中protobuf資料流的解析: https://wooyun.js.org/drops/Burpsuite%E4%B8%ADprotobuf%E6%95%B0%E6%8D%AE%E6%B5%81%E7%9A%84%E8%A7%A3%E6%9E%90.html
本文使用 mdnice 排版