0x4588 ≠ 4588:一個讓後量子從我們自己儀表板上消失的單字元錯誤

我們為通過代理程式的每一條連線開啟了後量子金鑰交換。而那個工作就是統計使用中加密演算法的儀表板,卻顯示為零。不是少—是零。以下就是這個錯誤、它為何藏了數週,以及最終揪出它的那一件事。

背景

接合會在前往伺服器途中的 ClientHello 裡注入一個後量子金鑰交換群組,好讓即使只懂傳統密碼的客戶端,也能最終進行 PQC 交握(運作方式見此)。我們剛把注入的群組更新為新的 IANA 最終版混合群組 X25519MLKEM768。

錯誤所在

在 TLS 中,代碼點按慣例一律以十六進位書寫—處處皆然。加密套件(0x1301)、命名群組(X25519 為 0x001D)、擴充;RFC、Wireshark、OpenSSL,以及我們自己的程式碼,全都以十六進位讀取它們。被編輯的那一行,原本就以十六進位字面值存放著前一個代碼點。所以「代碼點」就等於「寫成 0x…」。

但 IANA 登錄表是以十進位公布其數值的,而它將 X25519MLKEM768 列為 4588。那個數字正是陷阱:4588 全是 0–9 的數字—在兩種進位制下都是完全合法的數值,且沒有任何標記指出是哪一種。在「處處皆十六進位」慣例的引導下,我們把十進位的 4588 寫成了 0x4588。編譯器什麼也沒說,因為 0x4588 是完全合法的十六進位字面值。它只是等於 17800。

IANA 登錄表:      X25519MLKEM768 = 4588    (十進位)
我們送出的:       0x4588         = 17800   (十進位)  ← 未被指派
真正的值:         0x11EC         = 4588    (十進位)

因此代理程式送出的每一個 ClientHello 都宣告了群組 17800—一個未被指派的值。伺服器完全照規範行事:忽略未知群組,退回客戶端原本提供的方式。沒有協商出任何 PQC。這個升級在每一條連線上都悄悄地什麼也沒做。

它為何藏了數週

沒有當機、沒有錯誤、沒有測試亮紅燈。還有另外兩個錯誤,甚至讓它看起來一切正常。

首先,名稱對照表錯得恰好相反。它把真正的代碼點 0x11EC 對應到錯誤的標籤—一個較舊的 Kyber 草案—並把虛構的 0x4588 對應到「X25519MLKEM768」。所以當網際網路上真實的伺服器協商出貨真價實的 ML-KEM(0x11EC)時,我們的盤點把它歸到了 Kyber 草案。注入錯誤與標籤錯誤指向同一個方向:X25519MLKEM768 從兩端讀起來都是零。

其次,本該抓到它的那個測試,接受 0x4588 0x11ec 任一者為通過。測試所連結的真實 OpenSSL 產生的是 0x11ec,所以測試亮了綠燈—而 0x4588 那條分支就擺在那裡,從未被執行,卻讓整件事看起來通過了認證。

一個錯誤的常數、一個藏住正確值的錯誤標籤,以及一個有逃生門的測試。各自單獨或許都會被抓到;合在一起,它們形成了一個從內部看起來完全正確的封閉迴圈。

是什麼揪出了它

沒有任何內部手段能做到—每一個內部的真相來源都與自己一致。唯一的訊號,是一個本不該為零的數字。修正之道,是去問封包本身—它無法被你自己的常數所蒙騙:

openssl s_client -connect cloudflare.com:443 \
    -groups X25519MLKEM768 -trace

trace 會印出伺服器實際選擇的群組:十進位的 4588,十六進位的 0x11EC。握有真正的值後,其餘就是機械式的工作—把代碼點單一來源化,讓注入值、名稱對照表與測試都從同一個定義讀取,並把測試裡的「二擇一」改成單一的預期值。

教訓

四點,而且沒有一點與密碼學有關:

十六進位與十進位是登錄表邊界上的陷阱。IANA 公布十進位;C 字面值是十六進位。0x4588 ≠ 4588 正是那種會編譯、會通過審查、會上線的錯誤—語法完美、語意錯誤—正因為一個全數字的值在兩種進位制下都合法。

一個對本應只有單一答案的值接受「A 或 B」的測試,不是測試。它是多了幾道手續的橡皮圖章。

一個本該非零卻為零的數字,不是「沒有資料」,而是一條線索。儀表板運作得完美無瑕;它忠實地回報著:什麼都沒發生。

對任何在封包上的東西,封包是唯一的真相來源。你的常數、你的標籤、你的測試,可以彼此一致、卻一起錯。去探測那個真實的東西。

這就是「從位元組層級讀 TLS」在日常中真正的意思—不是背下規範,而是對你與位元組之間的每一層都抱持懷疑,包括你自己那一層。這也是為什麼當有人告訴我他們的環境「正在做後量子」時,我的第一個動作是一次即時 trace,而不是一個設定檔。封包是唯一不會對你說謊的東西。

← 所有文章