二進位協定運作在位元層級,最小的資料單位是單一個二進位數字。但直接處理單一位元很麻煩,所以我們改用 8 位元為一組的八位元組(octet),也就是俗稱的位元組(byte)。八位元組是網路協定事實上的基本單位。雖然八位元組可以再拆成個別位元(例如用來表示一組旗標),本書一律以 8 位元為單位看待網路資料。

在需要標示個別位元時,本書採用位元格式(bit format):最左邊是位元 7,即最高有效位元(most significant bit, MSB);最右邊是位元 0,即最低有效位元(least significant bit, LSB)

圖表 3-1:二進位資料的描述格式

部分架構(例如 PowerPC)的位元編號方向與此相反。看規格書時務必先確認它的編號慣例。

數值資料#

代表數字的資料值通常是二進位協定的核心。這些值可以是整數或小數,用途包括表示資料長度、識別標籤值,或單純就是一個數字。二進位中的數值有幾種不同表示法,協定選用哪一種取決於它要表達的內容。

無號整數#

無號整數(unsigned integer)是最直觀的表示法:每個位元依其位置具有特定權重,把這些權重加總就得到整數值。

位元十進位值十六進位值
010x01
120x02
240x04
380x08
4160x10
5320x20
6640x40
71280x80

有號整數#

並非所有整數都是正的。例如要表示兩個整數的差,就必須考慮結果可能為負,而只有有號整數(signed integer)能存放負值。CPU 面對的其實是同一組位元,因此需要一套規則來把無號位元樣式解讀成有號值——最常見的解讀方式是二補數(two’s complement)

在二補數中,無號與有號之間的轉換方式是:取整數的位元 NOT(0 變 1、1 變 0),然後加 1。

圖表 3-2:123 的二補數表示法

二補數有一個危險的安全後果:8 位元有號整數的範圍是 –128 到 127,最小值的絕對值大於最大值。對最小值取負得到的是它自己——-(-128) 仍然是 -128。這會讓解析格式時的計算出錯,進而造成安全漏洞。細節見第 10 章。

變長整數#

網路資料的高效傳輸在歷史上非常重要。即使今天的高速網路讓效率考量顯得不再必要,減少協定頻寬仍有其好處。當最常出現的整數值落在很小的範圍內時,使用變長整數(variable-length integer)會很划算。

圖表 3-3:7 位元整數編碼範例

以長度欄位為例:若傳送的資料區塊大小介於 0 到 127 位元組之間,可以採用 7 位元變長整數表示法。要涵蓋 32 位元的完整範圍最多需要 5 個八位元組,但如果協定實際上多半只用到 0 到 127,那就只會用掉 1 個八位元組,省下可觀空間。

如果解析超過 5 個八位元組(甚至超過 32 位元),結果取決於解析程式:有些(包括 C 寫的)會直接丟棄超出範圍的位元,有些開發環境則會產生溢位錯誤。處理不當時,這種整數溢位可能導致緩衝區溢位——配置了比預期小的記憶體緩衝區,接著造成記憶體毀損。

浮點資料#

有時整數不足以涵蓋協定所需的小數範圍。例如多人電腦遊戲的協定可能需要傳送玩家或物件在虛擬世界中的座標;若這個世界很大,很容易撞上 32 位元甚至 64 位元定點值的範圍上限。

最常用的浮點格式來自 IEEE Standard for Floating-Point Arithmetic 這份標準,一般簡稱 IEEE 754。該標準定義了多種二進位甚至十進位格式,但實務上你大概只會遇到兩種:32 位元的單精度與 64 位元的雙精度。每種格式各自規定了有效數(significand)與指數(exponent)的位置與位元數,另外還有一個符號位元表示正負。

圖表 3-4:浮點數的表示法

位元長度指數位元有效數位元數值範圍
32823+/– 3.402823 × 1038
641152+/– 1.79769313486232 × 10308

布林值#

布林值對電腦極為重要,出現在協定中毫不意外。每個協定各自決定如何表示真與假,但有些共通慣例。

  • 最基本的做法是用單一位元表示:0 為假、1 為真。空間效率高,但不見得最容易與底層應用程式介接。
  • 更常見的是用單一位元組來表示,因為操作起來方便得多。
  • 也很常見採用零表示假、非零表示真的慣例。

位元旗標#

位元旗標(bit flag)是在協定中表示多個特定布林狀態的一種方式。例如 TCP 就用一組位元旗標來表示連線的當前狀態:建立連線時,客戶端送出設有同步旗標(SYN)的封包,表示雙方連線應同步計時器;伺服器則可同時回應確認旗標(ACK)表示已收到客戶端請求,並附上 SYN 旗標與客戶端建立同步。

位元旗標的關鍵優勢在於狀態可以疊加。若這次交握改用單一列舉值,就不可能表達 SYN 與 ACK 同時成立的雙重狀態——除非額外定義一個獨立的 SYN/ACK 狀態。

位元組序#

位元組序(endianness)是正確解讀二進位協定的重要一環。只要傳輸多個八位元組組成的值(例如 32 位元字組)就會牽涉到它。位元組序源自電腦在記憶體中儲存資料的方式。

因為八位元組在網路上是循序傳送的,你可以選擇先送最高有效的八位元組,也可以反過來先送最低有效的。這個送出順序就決定了資料的位元組序。沒有正確處理位元組序,會在協定解析上造成難以察覺的錯誤。

圖表 3-5:大端序與小端序的字組表示法

現代平台主要有兩種格式:

格式定義代表架構
大端序(big-endian)最高有效位元組放在最低位址SPARC
小端序(little-endian)最低有效位元組放在最低位址x86

位元組序通常也被稱作網路序(network order)或主機序(host order)。因為網際網路 RFC 幾乎一律以大端序作為所有網路協定的偏好型別(除非有歷史包袱),大端序又被稱為網路序。但你的電腦本身可能是大端序也可能是小端序。

開發網路軟體時,不要對執行平台的位元組序做任何假設。用來建構應用程式的網路 API 通常會提供在兩種序之間轉換的便利函式。

延伸:可切換位元組序的架構與中端序

部分處理器架構(包括 SPARC、ARM、MIPS)具備在執行期指定位元組序的板載邏輯,通常透過切換一個處理器控制旗標來達成。

另外還有像 PDP-11 這類平台使用中端序(middle endian)格式,其中 16 位元字組會被對調。不過你在日常生活中幾乎不可能遇到,不必花心思在上面。

文字與人類可讀資料#

除了數值資料之外,字串是你最常遇到的值型別,不論是用來傳遞驗證憑證還是資源路徑。若協定只設計來傳送英文字元,文字大概會用 ASCII 編碼。原始 ASCII 標準定義了 0 到 0x7F 的 7 位元字元集,涵蓋表示英文所需的大部分字元。

圖表 3-6:7 位元 ASCII 對照表

ASCII 標準最初是為文字終端機(帶有移動列印頭的實體裝置)開發的。控制字元用來對終端機下指令以移動列印頭,或用來同步電腦與終端機之間的序列通訊。ASCII 字元集包含兩類字元:

  • 控制字元(control):多數是那些老裝置的遺物,幾乎已不使用。但少數在現代電腦上仍有意義,例如用來結束文字行的 CR 與 LF。
  • 可列印字元(printable):你看得見的那些,包含許多熟悉的符號與英數字。

問題是,7 位元數字不可能表示世界上各種語言中成千上萬字元的一小部分。對付這個限制常見有三種策略:代碼頁(code page)多位元組字元集(multibyte character set),以及 Unicode 標準。協定要嘛要求你使用其中一種,要嘛提供選項讓應用程式自行挑選。

代碼頁#

擴充 ASCII 最簡單的方式,是注意到既然資料都存在八位元組裡,128 到 255 這 128 個未使用的值就可以拿來存放額外字元。256 個值當然不足以容納所有語言的所有字元,但這段未使用範圍有很多種用法。哪個值對應哪個字元,通常記載在稱為代碼頁字元編碼(character encoding)的規格中。

多位元組字元集#

中文、日文、韓文(合稱 CJK)就算把 256 個位置用滿也遠遠不夠。解法是把多位元組字元集與 ASCII 結合起來編碼這些語言,常見的編碼有日文的 Shift-JIS 與簡體中文的 GB2312。多位元組字元集允許用兩個以上的八位元組依序編碼一個字元。不過實務上很少見——若你不處理 CJK,大概根本碰不到。

Unicode#

Unicode 標準於 1991 年首次標準化,目標是把所有語言納入一個統一字元集。你可以把 Unicode 想成另一種多位元組字元集,但它不像 Shift-JIS 只鎖定特定語言,而是試圖把所有書寫語言——包括一些古語與人造語——編入單一通用字元集。

Unicode 定義了兩個相關概念:

  • 字元對映(character mapping):數值與字元之間的對應關係,以及字元如何使用或組合的種種規則。
  • 字元編碼(character encoding):這些數值在底層檔案或網路協定中如何被編碼。

就分析目的而言,知道數值如何被編碼遠比字元對映重要。

Unicode 中每個字元都被指派一個碼點(code point)代表唯一字元,通常寫成 U+ABCD 的形式,ABCD 是碼點的十六進位值。為了相容性,前 128 個碼點與 ASCII 一致,接下來 128 個取自 ISO/IEC 8859-1。得到的值再以特定方案編碼,這些方案有時稱為 UCS(Universal Character Set)或 UTF(Unicode Transformation Format)編碼。

UCS 與 UTF 格式之間存在細微差異,但就辨識與操作而言並不重要。

圖表 3-7:字串 "Hello" 在不同 Unicode 編碼下的樣貌

三種常見的 Unicode 編碼:

編碼特性典型使用場合
UCS-2 / UTF-16以 16 位元整數序列編碼碼點,有大小端序變體現代 Microsoft Windows 平台,以及執行程式時的 Java 與 .NET 虛擬機
UCS-4 / UTF-32以 32 位元整數序列編碼碼點,有不同端序變體Unix 應用程式,因為它是許多 C/C++ 編譯器的預設寬字元格式
UTF-8不固定整數大小,以簡單的變長值編碼碼點Unix 上大概最常見;也是 XML 等多種平台與技術的預設輸入輸出格式

UTF-8 的編碼定義確保了 ASCII 字元集(碼點 U+0000U+007F)使用單一位元組編碼。這讓它不只與 ASCII 相容,還很省空間,同時也相容於依賴 NUL 結尾字串的 C/C++ 程式。

代價是中文、日文這類語言在 UTF-8 下比 UTF-16 佔用更多空間——不過即使是這種不利情況,UTF-8 仍比 UTF-32 省空間。

圖表 3-8:字串「兔子」在不同 Unicode 編碼下的樣貌

完整參考:UTF-8 碼點編碼規則
碼點位元數起始碼點 (U+)結束碼點 (U+)Byte 1Byte 2Byte 3Byte 4
0–70000007F0xxxxxxx
8–11008007FF110xxxxx10xxxxxx
12–160800FFFF1110xxxx10xxxxxx10xxxxxx
17–21100001FFFFF11110xxx10xxxxxx10xxxxxx10xxxxxx
22–262000003FFFFFF111110xx10xxxxxx10xxxxxx10xxxxxx
26–3140000007FFFFFFF1111110x10xxxxxx10xxxxxx10xxxxxx

不正確或天真的字元編碼處理是許多細微安全問題的來源,從繞過過濾機制(例如在請求的資源路徑上)到造成緩衝區溢位都有。第 10 章會探討與字元編碼相關的漏洞。

變長二進位資料#

如果協定開發者事先確知必須傳送哪些資料,他可以讓協定中所有值都是固定長度。實務上這相當罕見——即使是最簡單的驗證憑證,也會受益於能指定可變長度的使用者名稱與密碼字串。協定用來產生變長資料值的策略主要有四種。

終止式資料#

終止式資料(terminated data)定義一個終止符號,告訴解析器資料值到此為止。之所以選用該符號,是因為它不太可能出現在一般資料中,以確保值不會被提前終止。以字串資料而言,終止值可以是 NUL(以 0 表示)或 ASCII 集合中其他控制字元。

前面談過的變長整數其實就是終止式資料的例子——當八位元組的 MSB 為 0 時值即終止。這個概念可以延伸到字串或資料陣列這類元素。

若選定的終止符號在正常資料傳輸中就會出現,你需要一套跳脫(escape)機制。字串常見的做法是在終止字元前加上反斜線 \,或把它重複兩次,避免被誤判為終止符號。當協定事先不知道值有多長(例如值是動態產生的),這個做法特別有用。

另一種變形是有界資料(bounded data):終止符號與變長序列的第一個字元相同。例如字串資料常見被一對雙引號夾住,開頭的雙引號告訴解析器去尋找配對的字元來結束資料。

"Hello" 作為 NUL 終止字串:
48 65 6C 6C 6F 00
 H  e  l  l  o  NUL

"Hello" 作為雙引號有界字串:
22 48 65 6C 6C 6F 22
 "  H  e  l  l  o  "

圖表 3-9:"Hello" 作為 NUL 終止字串

圖表 3-10:"Hello" 作為雙引號有界字串

長度前綴式資料#

如果值的長度事先已知,就可以把長度直接寫進協定。解析器讀出這個值,再讀取相應數量的單位(字元或八位元組)取出原始值。這是指定變長資料非常常見的方式。

長度前綴本身的大小通常不那麼重要,但應該合理反映所傳送資料的型態。多數協定不需要用到 32 位元整數的完整範圍,但你仍常看到長度欄位用這個大小——單純因為它與多數處理器架構和平台契合。

"Hello" 作為 8 位元長度前綴字串:
05 48 65 6C 6C 6F
LEN  H  e  l  l  o

圖表 3-11:"Hello" 作為長度前綴字串

隱含長度資料#

有時值的長度隱含在周遭的值之中。例如協定用 TCP 這類連線導向協定把資料送回客戶端時,伺服器可以不預先指定資料大小,而是直接關閉 TCP 連線,隱含地表示資料結束——HTTP 1.0 的回應就是這樣傳回資料的。

圖表 3-12:"Hello" 作為隱含長度字串

另一種情況是上層協定或結構已經指定了一組值的長度。解析器先取出上層結構,再讀取其中包含的值;協定可以利用這個結構具有有限長度的事實,隱含地計算出某個值的長度(做法類似關閉連線,但不必真的關閉)。

填充式資料#

當值的長度有上限(例如 32 個八位元組)時,可以使用填充式資料(padded data)。為了簡化,協定不加長度前綴也不用明確的終止值,而是直接送出整個固定長度的字串,並用一個已知的值填滿未使用的部分來標示值的結尾。

"Hello" 以 '$' 填充至 8 位元組:
48 65 6C 6C 6F 24 24 24
 H  e  l  l  o  $  $  $

圖表 3-13:"Hello" 作為以 '$' 填充的字串

變長資料值大概是你在網路協定中會遇到最重要的結構。分析未知協定時,先問「這個值的長度是誰決定的」,往往比逐位元組硬解更快切入重點。