Accept、Accept-Language、Accept-Encoding 的數值與順序在 JavaScript 執行前就構成指紋——停用腳本也無法阻止。
封鎖所有腳本、停用 Cookie,甚至用 NoScript 把瀏覽器鎖得死死的——伺服器依然能給你打上指紋。每一次 HTTP 請求都攜帶一組標頭,說明你的瀏覽器接受什麼內容、使用什麼語言、支援哪種編碼,而這些標頭在任何一行 JavaScript 執行之前就已經送達。它們的數值、順序,以及哪些標頭會出現,都會因瀏覽器而異——這種差異相當穩定,很難偽造得天衣無縫,而且伺服器在收到第一個請求時就能讀到。這是一種完全不需要 Canvas、不需要 WebGL、不需要 navigator 物件的指紋識別——只靠請求行本身就夠了。
核心要點
- Accept、Accept-Language、Accept-Encoding 攜帶真實的熵。 它們的數值反映了已安裝的語言偏好與編解碼器支援,組合起來縮小訪客範圍的方式,和Canvas 或 WebGL如出一轍——只是完全不需要任何腳本運作。
- 標頭順序本身就是一個獨立於數值的信號。 每種瀏覽器的網路堆疊都以固定順序發出標頭;這個順序因 Chrome、Firefox、Safari 而異,且同一安裝版本發出的請求之間幾乎不會改變。
- 這一切都不需要 JavaScript。 伺服器在第一個請求上就能看到完整的標頭集合——早在 HTML 被解析之前,更別說腳本標籤運作之前——所以封鎖 JS 和 NoScript 對它都無能為力。
- 它位於 Client Hints 之下,而非取代 Client Hints。 User-Agent Client Hints 在始終會發送的 Accept 系列標頭之上,又疊加了一層 HTTP 層信號:少數低熵提示預設就會發送,而詳細的那些只有在伺服器透過
Accept-CH主動索取時才會發送。兩者都早於 JavaScript 觸發。 - 它會與下方的 TLS 和 TCP 層疊加。 TLS 指紋識別讀取的是這些標頭之下的加密交握過程,因此同時檢查這兩層的伺服器,能拿到一個完全不依賴頁面是否載入成功的請求級簽章。
請求行本身就已經是一個指紋
在瀏覽器渲染任何內容之前,它已經發出了一個 HTTP 請求,其中攜帶若干標頭,而這些標頭的全部作用就是內容協商:告訴伺服器瀏覽器能顯示什麼、用什麼語言,以及希望回應以何種方式壓縮。在 HTTPS 之下,這個請求在傳輸途中是加密的,但終結連線的那台伺服器讀到的是明文。一個典型的 Chrome 請求大致如下:
GET / HTTP/1.1
Host: example.com
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8
Accept-Language: en-US,en;q=0.9
Accept-Encoding: gzip, deflate, br, zstd
在 HTTP/2 與 HTTP/3 中,同樣的資訊以二進位壓縮欄位的形式傳輸,欄位名稱全部小寫,Host 也被 :authority 偽標頭取代——但內容協商的語意完全一致,「先於任何腳本抵達」這一點也完全一致。(分幀層本身還會帶來額外的信號,參見HTTP/2 指紋識別。)
這裡沒有任何一項需要腳本執行——這正是用來取得 HTML 文件本身的那個請求。僅記錄這三個標頭,伺服器就已經掌握了比大多數人想像中更多的資訊:Accept 系列標頭——Accept、Accept-Language、Accept-Encoding——絕非樣板文字,它們是按瀏覽器與設定逐一協商出來的;而 RFC 9110——目前的 HTTP 語意標準——定義了品質值(q=)語法,讓每個瀏覽器都能表達自己的偏好排序,而這正是差異的來源。
每個標頭究竟洩漏了什麼
Accept-Language 是最直接的信號。它按偏好順序列出使用者設定的語言,並用 q 值表示相對權重。多語言使用者——例如設定了 en-US,en;q=0.9,fr;q=0.8,de;q=0.7 的使用者——暴露出一個具體且相對罕見的組合;而具體的 q 權重格式也會因瀏覽器與作業系統地區設定的不同而略有差異,這又增添了一層資訊,不只是「設定了哪些語言」,還有「這一特定技術堆疊如何格式化語言清單」。
不過有一點值得說清楚:單看這個標頭,它的價值其實有限。一個只發送 en-US,en;q=0.9 的訪客,和數量龐大的人群共享同一個數值,因此它本身幾乎提供不了多少可識別資訊。熵集中在那些不尋常的情況裡——三四種語言的清單、罕見的地區變體、與出口 IP 所在國家對不上的語言——以及它與請求中其餘資訊的組合。這與 JavaScript 側那些信號所遵循的熵與匿名集數學是同一套邏輯。
Accept-Encoding 列出用戶端能夠解碼的壓縮演算法:gzip、deflate、br(Brotli),以及日益普及的 zstd。新演算法的支援是逐瀏覽器、逐版本推出的,因此具體的演算法集合——以及它們的列出順序——不僅能縮小到「某個 Chromium 瀏覽器」,還能大致定位到發出請求的版本範圍。
Accept 描述瀏覽器願意渲染哪些內容類型與子類型,並按偏好加權——它直接揭露了諸如是否支援 AVIF 或 WebP 影像格式這類資訊,而這與瀏覽器家族及版本高度相關。
單獨來看,每個標頭都只能略微縮小範圍。但組合起來讀取,它們的表現與Canvas 和 WebGL 指紋背後的熵計算完全一致:一組彼此獨立的信號,各自貢獻的可識別資訊位元會不斷累加。
標頭的順序與存在與否:藏在數值之下的簽章
會變化的不只是數值——瀏覽器發送標頭的集合與順序本身也幾乎是一個固定簽章。每種瀏覽器的 HTTP 用戶端函式庫都按自家網路程式碼建構標頭時的既有順序來組裝它們,這個順序在同一瀏覽器、同一版本的多次請求之間保持一致,但在不同瀏覽器之間各不相同。一個聲稱自己是 Chrome、標頭順序卻和 Python 的 requests 函式庫或 curl 如出一轍的請求,當場就露了破綻——讓 TLS ClientHello 順序成為指紋的那套邏輯,在更上一層——HTTP 分幀層——同樣適用於普通的標頭順序。而由於 HTTP/2 和 HTTP/3 在此之上又強加了各自的偽標頭慣例,分幀層本身也攜帶了額外的信號,普通 HTTP/1.1 代理或腳本函式庫往往會在這個細節上出現微妙的錯誤。
這也是為何標頭集合和任何單一數值一樣重要:真正的瀏覽器在每次導覽時都會發送一組可預測且完整的標頭;而一個只設定了 User-Agent、忘了 Accept-Language 或 Accept-Encoding——或以任何真實瀏覽器都不會採用的順序發送它們——的腳本化用戶端,恰恰會因為缺失或錯位的東西而暴露自己,而不是因為某個單一數值看起來不對勁。
為何這種方式能扛住停用 JavaScript
本站介紹過的那些 DOM 層指紋信號——Canvas、WebGL、音訊、字型、權限——都需要一段腳本運作並呼叫某個 API。而 HTTP 標頭完全不需要這些。它們由瀏覽器的網路堆疊附加在取得 HTML 第一個位元組的第一個請求上,這代表:
- 完全停用 JavaScript(NoScript、純文字瀏覽器)沒有任何效果——標頭照樣會發出去。
- 封鎖第三方腳本或追蹤器同樣幫不上忙,因為提供頁面的第一方伺服器能直接看到這些標頭。
- 即便一個請求從未渲染出頁面——一次 HEAD 請求,或一次從未走完的重新導向——依然攜帶完整的標頭集合。
正是這種特性讓 TLS 與 TCP/IP 指紋識別同樣能扛住腳本封鎖:任何存在於網路層或協定層、位於 DOM 和 JavaScript 引擎之下的信號,都完全不在依靠停用腳本運作的隱私工具的觸及範圍之內。
它如何與 Client Hints 和 TLS 疊加
僅憑 HTTP 標頭進行指紋識別並不是要取代其他網路層信號——它是這些信號共同依附的那一層,或與之並列的一層:
| 層級 | 信號 | 需要 JS? | 需要 HTTPS? |
|---|---|---|---|
| TCP/IP | 視窗大小、TTL、選項順序 | 否 | 否 |
| TLS 交握 | 來自 ClientHello 的 JA3/JA4 | 否 | 是 |
| 普通 HTTP 標頭 | Accept/Accept-Language/Accept-Encoding 的數值+順序 | 否 | 否 |
| Client Hints | Sec-CH-UA-* 低熵預設值 | 否 | 是 |
| Client Hints(高熵) | 完整版本號、架構、型號 | 否(但需伺服器發送 Accept-CH 索取) | 是 |
| 瀏覽器 API | Canvas、WebGL、字型、音訊 | 是 | 否 |
沿著這整套層級往下讀的伺服器,會得到一個完全在——且獨立於——任何基於 JavaScript 的信號之前就已組裝好的簽章,再為那些確實執行了腳本的訪客疊加上 DOM 層的信號。Accept 系列標頭與 Client Hints 都屬於 HTTP 層,也都不依賴 JS;差別在於 Client Hints 目前只有 Chromium 系瀏覽器支援,且更豐富的數值需要伺服器主動索取,而普通的 Accept 標頭則來自每一種瀏覽器的每一次請求,無需任何協商。
檢查你自己的標頭
這條分界線的兩側,你都可以親自看一眼。BrowserInsight 的指紋檢測工具會在一堆 JavaScript 側信號旁邊,顯示你的瀏覽器向腳本暴露的語言清單(navigator.languages);同時它還會顯示頁面自己算不出來的那一個網路層信號:伺服器觀測到的本次 TLS 交握,包括 JA3/JA4、TLS 版本與 ClientHello 長度。那張交握卡片對應的正是本文所講這些標頭之下的那一層——同樣具備「先於任何腳本」的特性,只不過讀它的是伺服器,而不是頁面。
想把它和標頭這一層對照著看,可以記下工具裡顯示的語言清單,然後用任何一個能回顯原始請求的服務,或者直接用瀏覽器自帶的開發者工具(打開 Network 面板,點開文件請求,查看 Request Headers),看看瀏覽器實際發出的 Accept-Language 是什麼。兩者在細節上往往並不一致——navigator.languages 與 Accept-Language 源自同一份偏好設定,但各自獨立格式化——而這種不一致,恰恰是檢測系統在找的東西。改一改語言設定,或換一個瀏覽器重做一遍,你能親眼看到兩側同時發生變化。
常見問題
使用 VPN 會改變這些標頭嗎?
不會。VPN 改變的是你的 IP 位址,而不是瀏覽器的網路堆疊——Accept、Accept-Language、Accept-Encoding 由瀏覽器自身產生,並原樣穿過 VPN 通道。如果你的 Accept-Language 仍然顯示 en-US,而 VPN 出口 IP 的地理定位卻指向另一個國家,這種矛盾本身就是一個信號,類似於其他工具會檢測的時區與 IP 不匹配。
我能偽造這些標頭,讓自己看起來像另一種瀏覽器嗎?
數值可以——任何 HTTP 用戶端函式庫都允許你設定任意標頭。但要在每一次請求中都始終如一地符合某個真實瀏覽器精確的標頭集合與順序,包括 HTTP/2 如何為它們分幀,就困難得多——這正是為何不匹配或不完整的標頭集合常常成為腳本化流量的破綻,而不僅僅是數值本身被偽造。
這和 TLS 指紋識別是一回事嗎?
相關但不同。TLS 指紋識別讀取的是在任何 HTTP 請求發出之前就完成的那次交握;而 HTTP 標頭指紋識別讀取的是交握完成後緊接著發出的那個請求——它在傳輸途中是加密的,但終結連線的伺服器能完整讀到。兩者都不依賴 JavaScript,而伺服器越來越傾向於同時檢查這兩層,因為一個請求完全可能通過其中一項檢查卻在另一項上露餡。
停用 Cookie 能阻止這種指紋識別嗎?
不能——Cookie 與此無關。基於標頭的指紋識別不會在你的裝置上儲存任何東西;它讀取的是瀏覽器在每次請求中預設發送的內容,這和廣義上的瀏覽器指紋識別同樣不依賴任何儲存的資料。
結論
多數關於指紋識別的討論都圍繞 JavaScript 驅動的信號展開——Canvas 雜湊、WebGL 渲染器字串、字型列舉——因為這些是追蹤者可用的、熵值最高的資訊來源。但在更下面一層,還存在一個有意義的簽章:每個瀏覽器在任何腳本有機會運作之前發送的那些普通 HTTP 標頭——接受什麼、用什麼語言、支援什麼編碼,以及它們的順序。停用 JavaScript 能關閉 DOM 層的信號;但對請求行本身毫無影響。與其下方的 TLS、TCP 指紋以及與之並列的 Client Hints 結合起來,這個僅憑標頭就存在的信號層代表:根本不存在真正「無信號」的請求——只存在沒人願意深挖到頁面之下的請求。
延伸閱讀:


