知乎直播彈幕抓取與解析

gaojin發表於2020-04-03

背景

因為想拿到一些知乎彈幕的資料 以及做一個直播播報機器人,所以最近在研究知乎直播的彈幕

分析

抓取比較簡單,不多說了...都是正常的操作

但是 拿到的資料卻很奇怪

為了演示方便,我們以 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

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
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 對應出某個位置的具體欄位名字就好了...

最後看一個成功的截圖

參考資料

[1]

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 排版

相關文章