前兩天我們看完了傳統的 CL.TE / TE.CL,以及進階到 HTTP/2 Downgrade 時利用偽標頭與 CRLF 注入的走私手法。
今天我們要來看一個在 2026 年被 LLM 挖出來的 CVE-2026-50052: My Cousin Vinyl [1],發現團隊跟上一個介紹的 CASE 一樣是 calif.io。
它有個比較有名的名字 Varnish Cache——這個於 2006 年問世的開源 HTTP 加速器在 2026 年正式更名為 Vinyl Cache。
Vinyl 的核心特色包括:
\r\n 分隔的 HTTP/1.1。以上介紹由 Gemini 生成,簡言之它就是個 前端 Server!
對不起原諒我也不太會介紹這東西
以下手打:
這個漏洞發生在 Vinyl 處理 HTTP/2 HPACK 解碼時的偽標頭分發邏輯中。
Tstrcmp在 include/vdef.h 中,Vinyl 定義了一個比對結構體字串的巨集:
// include/vdef.h
#define Tstrcmp(t, s) (strncmp((t).b, (s), Tlen(t)))
注意到問題了嗎?沒輟!
strncmp 的第三個參數 n,傳入的是 Tlen(t)(即輸入的 Header 名稱長度),而不是目標比對字串 s 的長度!
接著看 cache_http2_hpack.c 中的 h2h_addhdr() 函式:
// cache_http2_hpack.c - h2h_addhdr()
if (!Tstrcmp(nm, ":method")) {
...
} else if (!Tstrcmp(nm, ":path")) {
...
} else if (!Tstrcmp(nm, ":scheme")) {
...
} else if (!Tstrcmp(nm, ":authority")) {
memcpy(d->out + 6, "host", 4);
hdr.b += 6;
}
當 Client 傳送一個名為 :a 的偽標頭時:
nm 是 :a,Tlen(nm) 為 2。Tstrcmp(nm, ":authority") 會展開成 strncmp(":a", ":authority", 2)。:a,比對結果回傳 0(代表相等)!這意味著只要是 :authority 的任意前綴(:a、:au、:auth 等),都會被 Vinyl 誤認為是合法的 :authority 標頭,至此就發生了 collision。
為什麼辨識成 :authority 會這麼致命?這牽涉到 Vinyl 為了效能所做的 In-place 覆寫機制:
:authority 轉到 HTTP/1.1 時對應的是 host 標頭。d->out 的內容是 :authority: example.com。"host"(把 rity 蓋掉),並將標頭起始指標 hdr.b 往後移動 6 個 bytes(hdr.b += 6)。:autho,直接指向 host: example.com。但如果我們送的是偽標頭 :a 呢?
:a: ""::a: ),此時強制執行 hdr.b += 6,會導致 hdr.b > hdr.e。在後續程式碼觸發 assert(b <= e),導致 Worker 程序 crash(僅能造成 DoS)。:a: xx::a: xx),整個標頭長度剛好是 6 bytes(:a + : + xx)。hdr.b += 6 後,hdr.b 恰好等於 hdr.e(hdr.b == hdr.e)!這樣不會觸發 assert 崩潰,而是成功建立了一個大小為 0 的 Header。
當 Vinyl 把請求轉譯為 HTTP/1.1 寫入後端 TCP 連線時,會走 HTTP1_Write:
unsigned HTTP1_Write(struct v1l *v1l, const struct http *hp, const int *hf) {
...
l = http1_WrTxt(v1l, &hp->hd[hf[0]], " "); // METHOD + " "
l += http1_WrTxt(v1l, &hp->hd[hf[1]], " "); // URL + " "
l += http1_WrTxt(v1l, &hp->hd[hf[2]], "\r\n"); // PROTO + "\r\n"
// 遍歷所有 Header,寫入內容並在結尾加上 \r\n
for (u = HTTP_HDR_FIRST; u < hp->nhd; u++)
l += http1_WrTxt(v1l, &hp->hd[u], "\r\n");
l += V1L_Write(v1l, "\r\n", -1); // 最後的空行結束 Header 區塊
return (l);
}
當處理到那個長度為 0 的 Header 時:
\r\n。在 HTTP/1.1 規範中,連續的 \r\n\r\n 就代表 Header 區塊結束。後端收到這個單獨的 \r\n 就會認為 Header 已經讀完了,但此時 Vinyl 其實還在發送後面剩下的合法 Headers。
來講講這個漏洞怎麼擴展:
若直接截斷,後端會把殘留的標頭當作未知的 Garbage Request 而噴 400 錯誤並中斷連線。但攻擊者可以利用 HTTP/2 偽標頭順序沒有強制約束的特點:
提前宣告 Content-Length:
攻擊者在 :a 之前先塞入一個 content-length: <殘留長度>。
[前端 H2 視角]
content-length: 109
:a: xx
(其他正常 headers...)
[DATA frame: 109 bytes] + [第二段走私的 HTTP/1.1 請求]
後端解析被切斷:
後端看到 content-length: 109 後,在遇到 :a 產生的裸露 \r\n 時判定 Header 結束。接著後端依據 Content-Length 將 Vinyl 隨後送出的殘留 Header 當作 Body 吞掉剛好 109 bytes。
走私請求登場:
緊接在後面的 HTTP/2 DATA frame 內容直接露出,被後端連線當作下一個全新進來的 HTTP/1.1 請求解析!
這時候就可以透過這樣來 somehow 一樣(繞過前端 server block),因為夾帶進去作為 Data Frame 的 HTTP/1.1 請求就直接被放去後端 Server 了,抑或是:
Content-Length(例如 4096)的 POST /api/review。/account 的合法請求送到同一個連線池連線上,後端會直接把受害者的整個請求內容(包含 Cookie、Authorization: Basic ...)當成攻擊者剛才那個 POST 請求的 Body 存入資料庫或評論區,達成無感竊取憑證!修補方式非常乾脆:淘汰非對稱的 Tstrcmp,改用嚴格長度檢查的 Tstreq:
// include/vdef.h
#define Tstreq(t, s) (Tlen(t) == strlen(s) && !strncmp((t).b, (s), Tlen(t)))
改成先去確認長度一樣就好 owob