我們已經知道這個協定一部分是二進位、一部分是文字,這代表只看可列印文字不足以推出協定的各種結構。要往下挖,得改看十六進位傾印(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,主視窗會多出一列下拉選單,依序設定:
- 第一個選擇框:指定搜尋位置為 Packet bytes。
- 第二個選擇框:維持 Narrow & Wide(同時搜尋 ASCII 與 Unicode 字串)。
- Case sensitive 不勾選;第三個下拉選單選 String。
- 輸入要找的字串
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 0d、00 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 完成。
匯出步驟:
- 在 Show and save data as 下拉選單選 Raw。
- 按 Save As,把出站封包匯出成二進位檔
bytes_outbound.bin。 - 切換到入站對話,重複上述步驟,存成
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) 是常見做法,最典型的校驗和就是把所有要檢查的位元組相加。把上述兩個封包的位元組加總:
| 未知值 | 資料位元組 | 位元組總和 |
|---|---|---|
| 6 | 6 | 6 |
| 1 | 1, 0 | 1 |
要進一步驗證這個猜測在更大、更複雜的封包上也成立,有兩個簡單做法:
- 送出遞增的簡單訊息(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 幫我們扛掉大部分雜事。