我們已經知道這個協定一部分是二進位、一部分是文字,這代表只看可列印文字不足以推出協定的各種結構。要往下挖,得改看十六進位傾印(hex dump)。

切換到 Hex Dump 檢視#

回到 Wireshark 的 Follow TCP Stream 畫面,把 Show and save data as 下拉選單改成 Hex Dump。畫面會變成三個欄位:

  • 最左欄:該方向串流中的位元組位移(byte offset)。位移 0 是該方向送出的第一個位元組,位移 4 是第五個,依此類推。
  • 中間欄:位元組的十六進位傾印。
  • 最右欄:ASCII 表示,也就是先前在文字檢視看到的內容。

圖表 5-7:串流的 Hex Dump 檢視

每一個區塊就是一個封包#

Hex Dump 檢視中,中間欄的位元組區塊長度不一致:可能先是幾個 4 位元組的區塊,接著一個 1 位元組的區塊,最後才是一大段文字資料。這和純文字檢視裡「除了方向以外全部連成一片」的樣子截然不同。

這些區塊其實就是個別的封包:每一塊都是一個 TCP 封包(或稱區段,segment)。TCP 是串流式(stream-based)協定,讀寫 socket 時連續資料塊之間並沒有真正的邊界;但從物理層面看,根本不存在真正串流式的網路傳輸協定——TCP 實際上還是送出一個個封包,每個封包有 TCP 標頭(含來源與目的連接埠)加上資料。

用 Find Packet 驗證「一個區塊 = 一個封包」

要證明 Wireshark 顯示的確實是單一 TCP 封包,可以拿 Hex Dump 中的第一個值 BINX 來驗證。選擇 Edit ▸ Find Packet,主視窗會多出一列下拉選單,依序設定:

  1. 第一個選擇框:指定搜尋位置為 Packet bytes
  2. 第二個選擇框:維持 Narrow & Wide(同時搜尋 ASCII 與 Unicode 字串)。
  3. Case sensitive 不勾選;第三個下拉選單選 String
  4. 輸入要找的字串 BINX,按 Find

主視窗會自動捲動並標示第一個含有 BINX 的封包。從中間窗格可以看到這個封包只有 4 個位元組,底部窗格則顯示原始資料正是 BINX。這就證實了 Hex Dump 檢視中的區塊邊界就是封包邊界——BINX 自成一個封包。

圖表 5-8:在 Wireshark 主視窗中找到該封包

推敲協定結構#

為了簡化分析,一次只看單一方向的通訊。回到 Follow TCP Stream 的 Hex Dump 檢視,從方向下拉選單選擇「用戶端到伺服器(連接埠 12345)」,再按 Save as… 把出站的十六進位傾印存成文字檔,檢視起來會方便得多。

圖表 5-9:只顯示出站方向的十六進位傾印

取樣如下:

00000000   42 49 4e 58                                       BINX
00000004   00 00 00 0d                                       ....
00000008   00 00 03 55                                       ...U
0000000C   00                                                .
0000000D   05 61 6c 69 63 65 04 4f 4e 59 58 00               .alice.O NYX.
00000019   00 00 00 14                                       ....
0000001D   00 00 06 3f                                       ...?
00000021   03                                                .
00000022   05 61 6c 69 63 65 0c 48 65 6c 6c 6f 20 54 68 65   .alice.H ello The
00000032   72 65 21                                          re!
--snip--

從這段傾印可以讀出幾件事:

  • 開頭的 BINX 是魔術值(magic value)。這四個字元在串流其餘部分不再出現,而且比較不同工作階段,開頭永遠是同樣四個字元。直覺判斷:這是用戶端送給伺服器、用來表明「我是合法用戶端,不是隨便連上這個連接埠的別的程式」的識別。
  • 接著是重複出現的四個區塊:兩個 4 位元組區塊(00 00 00 0d00 00 03 55)、一個 1 位元組區塊(00),以及一個較大、含大量可讀文字的區塊。

第一個 4 位元組是長度嗎?#

第一個 4 位元組區塊會不會是個小數字,例如 0x0D(十進位 13)?回想第 3 章談過的 TLV(Tag, Length, Value)模式——每個資料區塊前面帶一個表示後續資料長度的值。這個模式對串流式協定(例如跑在 TCP 上的協定)特別重要,否則應用程式根本不知道要從連線讀多少資料才算讀完一則訊息。

來驗算:把那四個區塊的位元組全部加起來是 21 位元組,比預期的 13 多了 8。若長度欄位不計算自己(扣掉 4 位元組),剩下 17,離 13 還差 4。而那個未知的 4 位元組區塊若也不計入,就剛好是 13。

到這一步為止全是推測。推測本身沒有問題,問題是停在推測。協定分析的紀律是:一旦形成假設,就立刻找方法讓它「有機會被證偽」。

驗證假設:不要再盯著傾印看#

到了這個階段,繼續盯著十六進位傾印看已經不是有效率的做法。更快的驗證方式是:把串流資料匯出,寫一小段程式去剖析它的結構。本章稍後會把驗證工作搬進 Wireshark GUI,這裡先用命令列上的 Python 完成。

匯出步驟:

  1. Show and save data as 下拉選單選 Raw
  2. Save As,把出站封包匯出成二進位檔 bytes_outbound.bin
  3. 切換到入站對話,重複上述步驟,存成 bytes_inbound.bin

接著用 xxd(或類似工具)確認資料確實匯出成功:

$ xxd bytes_outbound.bin
匯出後的 xxd 輸出範例
00000000: 4249 4e58 0000 000f 0000 0473 0003 626f   BINX.......s..bo
00000010: 6208 7573 6572 2d62 6f78 0000 0000 1200   b.user-box......
00000020: 0005 8703 0362 6f62 0c48 6f77 2061 7265   .....bob.How are
00000030: 2079 6f75 3f00 0000 1c00 0008 e303 0362    you?..........b
00000040: 6f62 1654 6869 7320 6973 206e 6963 6520   ob.This is nice
00000050: 6973 6e27 7420 6974 3f00 0000 0100 0000   isn't it?.......
00000060: 0606 0000 0013 0000 0479 0505 616c 6963   .........y..alic
00000070: 6500 0000 0303 626f 6203 576f 6f00 0000   e.....bob.Woo...
00000080: 1500 0006 8d02 1349 276d 2067 6f69 6e67   .......I'm going
00000090: 2061 7761 7920 6e6f 7721                   away now!

用 Python 剖析協定#

因為只是從檔案讀資料,不需要寫任何網路程式碼,只要開檔讀取即可。我們需要讀取二進位資料——具體來說是網路位元組序(network byte order)的整數,用於長度與那個未知的 4 位元組區塊。

設計上有個關鍵原則:只要有任何一點不對勁,腳本就要立刻失敗。例如長度說有 100 位元組但只讀得到 20,就必須拋出例外。反過來說,如果整個檔案剖析完都沒有錯誤,我們對分析正確性的信心就大幅提高。

read_protocol.py:剖析協定資料的 Python 腳本(Python 2/3 皆可)
from struct import unpack
import sys
import os

# Read fixed number of bytes
def read_bytes(f, l):
    bytes = f.read(l)
    if len(bytes) != l:
        raise Exception("Not enough bytes in stream")
    return bytes

# Unpack a 4-byte network byte order integer
def read_int(f):
    return unpack("!i", read_bytes(f, 4))[0]

# Read a single byte
def read_byte(f):
    return ord(read_bytes(f, 1))

filename = sys.argv[1]
file_size = os.path.getsize(filename)
f = open(filename, "rb")
print("Magic: %s" % read_bytes(f, 4))

# Keep reading until we run out of file
while f.tell() < file_size:
    length = read_int(f)
    unk1 = read_int(f)
    unk2 = read_byte(f)
    data = read_bytes(f, length - 1)
    print("Len: %d, Unk1: %d, Unk2: %d, Data: %s"
        % (length, unk1, unk2, data))

腳本的組成:

  • read_bytes() 從檔案讀取固定數量的位元組,讀不足就拋出例外表示錯誤。
  • read_int() 以網路位元組序(最高有效位元組在前)讀取 4 位元組整數;read_byte() 讀取單一位元組。
  • 主體先讀 4 位元組的魔術值(預期是 BINX),然後進入迴圈,只要還有資料就持續讀出長度、兩個未知值與資料並印出。

對出站檔案執行後,全部資料都能剖析完畢且不產生錯誤——這證實了「第一個 4 位元組區塊是資料長度」的推測:

$ python3 read_protocol.py bytes_outbound.bin
Magic: b'BINX'
Len: 15, Unk1: 1139, Unk2: 0, Data: b'\x03bob\x08user-box\x00'
Len: 18, Unk1: 1415, Unk2: 3, Data: b'\x03bob\x0cHow are you?'
Len: 28, Unk1: 2275, Unk2: 3, Data: b"\x03bob\x16This is nice isn't it?"
Len: 1, Unk1: 6, Unk2: 6, Data: b''
Len: 19, Unk1: 1145, Unk2: 5, Data: b'\x05alice\x00\x00\x00\x03\x03bob\x03Woo'
Len: 21, Unk1: 1677, Unk2: 2, Data: b"\x13I'm going away now!"

用 Python 3 執行,顯示二進位字串的效果比 Python 2 好得多。

處理入站資料#

拿同一支腳本去跑匯出的入站資料,會立刻噴錯——因為入站協定沒有 BINX 魔術字串

$ python3 read_protocol.py bytes_inbound.bin
Magic: b'\x00\x00\x00\x02'
Length: 1, Unknown1: 16777216, Unknown2: 0, Data: b''
Traceback (most recent call last):
  ...
Exception: Not enough bytes in stream

只要在開檔之後加上一行,檢查魔術值,不符就把檔案指標重設回開頭:

if read_bytes(f, 4) != b'BINX': f.seek(0)

修改後,入站資料也能順利剖析:

$ python3 read_protocol.py bytes_inbound.bin
Len: 2, Unk1: 1, Unk2: 1, Data: b'\x00'
Len: 36, Unk1: 3146, Unk2: 3, Data: b"\x03bob\x1eI've just joined from user-box"
Len: 18, Unk1: 1415, Unk2: 3, Data: b'\x03bob\x0cHow are you?'

這個錯誤其實是好事。它一方面確認了「魔術值只在用戶端到伺服器方向出現」這個事實,另一方面也示範了「腳本設計成會早早失敗」的價值——錯誤本身就是資訊。

挖掘未知欄位:Unk1 是校驗和#

先看 Unk1。它每個封包都不同,但數值都不大,範圍在 1 到 3146 之間。最有啟發性的是這兩筆(一筆出站、一筆入站):

OUTBOUND: Len: 1, Unk1: 6, Unk2: 6, Data: b''
INBOUND:  Len: 2, Unk1: 1, Unk2: 1, Data: b'\x00'

兩筆的 Unk1等於 Unk2。而且第二筆長度是 2,涵蓋了 Unk2 加上一個值為 0 的資料位元組;第一筆長度是 1,Unk2 之後沒有資料。這暗示 Unk1 可能與封包資料直接相關。

網路協定加上校驗和(checksum) 是常見做法,最典型的校驗和就是把所有要檢查的位元組相加。把上述兩個封包的位元組加總:

未知值資料位元組位元組總和
666
11, 01

要進一步驗證這個猜測在更大、更複雜的封包上也成立,有兩個簡單做法:

  • 送出遞增的簡單訊息(A、B、C……),擷取後分析。若校驗和是單純加總,每則遞增訊息的值也應該遞增 1。
  • 在腳本中加上校驗和計算函式,把網路擷取到的 Unk1 和自己算出來的值比對。
def calc_chksum(unk2, data):
    chksum = unk2
    for i in range(len(data)):
        chksum += ord(data[i:i+1])
    return chksum

結果是吻合的——算出來的數字與 Unk1 相同。協定結構又解開一塊。

Unk2 是 TLV 的 Tag#

接著是 Unk2。它被算進封包資料的一部分(校驗和涵蓋它),所以應該和「送的是什麼」有關;但它在網路上是以單一位元組寫出的,這又顯示它其實與資料分離。合理推測:Unk2 就是 TLV 模式中的 Tag,而 Length 對應 Value 的長度。

驗證方法是盡可能操作 ChatClient,把所有指令都試一遍並擷取結果,再比對同類型指令的 Unk2 是否一致。以 bob 的第一個工作階段為例,前三個封包是:

Unk2: 0, Data: b'\x03bob\x08user-box\x00'
Unk2: 3, Data: b'\x03bob\x0cHow are you?'
Unk2: 3, Data: b"\x03bob\x16This is nice isn't it?"
*SNIP*

第一個封包(Unk2 為 0)不對應我們輸入的任何內容。後兩個封包的內容正是我們送出的兩則訊息,Unk2 都是 3。據此可以推斷 3 代表「送出訊息」的封包類型——而分析其他含訊息的工作階段,也確實每次送訊息時都是 3。

把所有工作階段對照下來,可以推得下列指令表:

指令編號方向說明
0出站用戶端連上伺服器時送出
1入站用戶端送出指令 0 之後,由伺服器回送
2雙向用戶端使用 /quit 時送出,伺服器回應時也用
3雙向用戶端送出給所有使用者的訊息;伺服器轉發訊息時也用
5出站用戶端使用 /msg 指令時送出
6出站用戶端使用 /list 指令時送出
7入站伺服器回應 /list 指令

分析到這個階段,作者的做法是回頭把「在用戶端做的每個動作」和「網路上送出的訊息」逐一對應,同時把「從伺服器收到的訊息」和「用戶端的輸出」對應起來。當指令與網路結果是一對一時這很容易;但更複雜的協定與應用程式往往沒這麼直觀,需要大量的對照與測試,才能找出協定各部分所有可能的值。

我們建出了指令表,但仍不知道每個指令的資料是怎麼表示的。要繼續分析那些資料,處理裸的二進位檔並不好用——下一步是回到 Wireshark,寫程式碼把協定解析出來並直接顯示在 GUI 裡,讓 Wireshark 幫我們扛掉大部分雜事。