vatt'ghern jaskier's ballads

Chrome 148 起,同一句 Math.tanh(0.8)、同一份程式碼,在 Linux、macOS、Windows 上會算出三個不同的浮點數——差距只藏在最後一兩個位元,卻足以讓伺服器單靠一次呼叫猜出你用哪套作業系統。

Chromium 148 起 Math.tanh 可被指紋化,洩漏底層 OS

一段程式碼,在不同機器上該給出同一個答案——這是多數工程師對浮點數運算的預設信任。Scrapfly 的技術部落格從一次違反這個信任的量測開始:呼叫 Math.tanh(0.8),Chrome 150 在 Linux 上回傳 0.6640367702678491,macOS 回傳 0.664036770267849,Windows 回傳 0.6640367702678489。三個字串幾乎一樣,卻沒有一個完全相同,最大差距落在兩個 ULP 以內,量小到可以忽略,卻已經是一枚能單獨拿來反推系統版本的指紋。

這類洩漏之所以值得認真看待,是因為它完全不需要使用者授權、不需要讀取任何敏感 API,也不會出現在瀏覽器的權限提示裡——單純呼叫一個標準函式,就能推回底層系統。對於想要偽裝成真人流量的自動化工具,這是必須清乾淨的訊號;對於想要辨識自動化流量的一方,這是幾乎零成本的新增訊號。Scrapfly 把自己的爬蟲瀏覽器描述成「built to stay indistinguishable from real traffic」,這篇文章要處理的正是這句承諾底下最刁鑽的一塊。

對正在寫瀏覽器自動化、爬蟲、或者反機器人系統的工程師來說,這不是一則單純的冷知識。它給出一份具體的檢查清單:哪些 API 已知會洩漏、洩漏的機制是什麼、要怎麼驗證自己的偽裝是不是真的做到位元對位元一致,而不是只在 User-Agent 或 Canvas 指紋這幾個老掉牙的地方打轉。

同一句 Math.tanh(0.8),三台電腦吐出三種答案

把三個字串攤開逐位比對,前十四位完全一致,分歧只出現在小數點後第十五、十六位。這個位置很穩定,每次量測都落在同一個地方,不是隨機擾動該有的樣子。如果差異是雜訊,它應該飄忽不定;如果是某種固定機制造成的,它應該可以重複量到、也應該可以解釋。這正是整篇調查的起點:如果不是雜訊,是什麼在最後一兩位動了手腳?

要確認這一點,最直接的辦法是把同一句呼叫重複量測、把輸出攤開成字元序列逐位比對,而不是只看最後印出來的十進位字串長什麼樣子。字串印出來的長度本身也是一條線索:三個平台印出的位數並不完全一樣,這通常代表雙精度浮點數在轉成十進位字串時,選的是能還原回同一個 bit 的最短表示法,而不是固定印出十七位——換句話說,字串長度的差異本身就已經在暗示,背後跑的不是同一份程式碼。

這次改動挑中的剛好是 tanh——一個在類神經網路的激勵函式、音訊動態壓縮,乃至部分介面動畫的緩動函式裡都會用到的函式。它不是防禦清單上最顯眼的項目,卻剛好是這次洩漏的起點,這也說明瀏覽器指紋防禦要窮舉所有可能呼叫到 host libm 的函式,本來就很困難。

拖曳滑桿改變 x,即時看你這台裝置算出的 Math.tanh(x) · 已知三態分岔點在 x = 0.8

0.80
x tanh(x) x = 0.8 已知分岔點

目前 x 的計算結果:Math.tanh(0.80) = 0.6640367702678491(此為預設值;桌面版拖曳滑桿後由你自己瀏覽器即時重算)

Scrapfly 記錄的三個真實平台,同樣輸入 x = 0.8,量測於 Chrome 150:

Linux/glibc 0.6640367702678491 macOS/libsystem_m 0.664036770267849 Windows/UCRT 0.6640367702678489
曲線是真正的 tanh(x);三態分岔的對照僅在 x = 0.8 有紀錄可查,其他 x 值上三個系統會不會分歧,這裡不假裝知道。

下方這個元件把 tanh(x) 的真正曲線畫出來,滑桿可以拖到任何 x;但目前留下量測紀錄的分岔點只有 x = 0.8 這一個——這也是整篇調查唯一站得住腳的具體證據,其餘 x 值上三個系統會不會分歧、分歧多少,Scrapfly 沒有公開逐點資料,這裡不假裝知道更多。

假設一:這只是浮點數天生的抖動?

第一個念頭最直覺:三台機器的 CPU、編譯器、甚至指令排程都不一樣,浮點誤差本來就會在最後幾位漂移,這會不會只是單純的雜訊?IEEE 754 規定的是一顆 double 該怎麼存,並沒有規定 sincostanhexp 這類超越函式的結果必須「正確捨入」——要讓每一步都捨入到最接近的可表示值,成本太高,於是每個平台都自己實作一套近似。這聽起來像是替「隨機抖動」背書,但抖動和「近似」不是同一件事。

所謂 minimax 多項式,是在一個區間裡找一個次數固定的多項式,讓它跟真正的 tanh 函式之間「最大的誤差」盡量小——這是逼近超越函式最常見的手法,因為多項式只需要加法和乘法就能算,比真的去解雙曲函式的定義式快得多。問題是「怎麼選這個多項式」沒有唯一答案:次數、係數、要不要先做範圍歸約(把輸入先縮小到一個容易逼近的區間,再換算回去),每一家函式庫的工程團隊都可以自己決定,只要最後的誤差落在他們自己訂的容忍範圍內。

排除雜訊假設看似繁瑣,卻是整個調查裡最關鍵的一步:如果分歧真的是隨機雜訊,後面所有想從「找出哪一段程式碼造成分歧」下手的努力都會白費——雜訊沒有可以定位的程式碼可以找。只有先確認分歧是固定的、可重複的,才值得往下追一段具體的實作差異。

拖曳滑桿選一個小數位,看三個平台在那一位是否還相同 · x = 0.8 的十六位小數

15
Linux/glibc 0.6640367702678491
macOS/libsystem_m 0.664036770267849
Windows/UCRT 0.6640367702678489

第 15 位:Linux 是 9,macOS 是 9,Windows 是 8。

把游標停在第十五位,Linux 和 macOS 都印出 9,Windows 已經是 8;再往後一位,連三家字串有幾位數都對不上——macOS 印出的整串比另外兩家短一位。抖動應該是每次呼叫都不一樣、位置也不固定;這裡的分歧位置卻死死釘住,而且同一台機器再呼叫十次、一百次,答案不會變。這不是雜訊會有的行為,而是三套各自寫死的近似多項式,在同一個輸入上各自捨入到了不同的終點。問題於是從「這是不是雜訊」,變成「差的到底是哪一段程式碼」。

這也解釋了為什麼分歧會落在固定位置,而不是隨機位置——三套多項式的逼近誤差都被壓在很小的範圍內,通常只在最後一兩個位元打轉,不會大到影響十進位字串前面十幾位。分歧的位置固定,正是因為背後的近似方法本身就是固定的、可重複的,不是隨機亂數在搗亂。

假設二:是不是整個 V8 的數學函式庫都換了?

如果懷疑的對象是「V8 換了它的 libm」,下一個合理的檢查是:其他 Math.* 函式是不是也一起分岔了。答案是否定的。V8 把自己的數學函式庫靜態連結進二進位檔——Math.expMath.powMath.atan 等大多數函式來自打包好的 llvm-libc,Math.sinMath.cos 則來自打包好的、源自 glibc 的 dbl-64 常式,在每一個平台上都是同一份二進位、同一組係數,結果全平台一致。

V8 選擇把大多數數學函式靜態連結進二進位檔,合理的考量是可預期性——同一份 JavaScript 在任何平台上執行,得到的浮點結果都應該一樣,這對測試、除錯、甚至跨平台的數值穩定性都重要。這也是為什麼發現 tanh 走了不同的路才顯得意外:它打破了 V8 原本一貫維持的跨平台一致性,而且是悄悄打破的,沒有改變函式簽章、沒有拋出任何錯誤或警告,呼叫端唯一能感知到的,只有最後一兩位數字。

反過來想也一樣說得通:如果分歧真的是「整個 V8 換了呼叫路徑」造成的,那麼像 Math.exp 這種在數值運算裡出現頻率更高、應用範圍更廣的函式,理應也一起分岔——但它沒有。這種「高頻率函式不受影響、單一函式受影響」的分布,本身就是指向「單一改動」而不是「系統性改動」的證據。

函式底層實作是否洩漏 OS
Math.tanh平台 std::tanh(host libm,Chrome 148 起)洩漏
Math.exp / Math.pow / Math.atan內建 llvm-libc(靜態連結)不洩漏
Math.sin / Math.cos內建、源自 glibc 的 dbl-64 常式(靜態連結)不洩漏
CSS sin() / cos() / atan2() 等(calc()Blink 角度化簡後直接呼叫平台 std::sin洩漏(七個函式全部)
Web Audio DynamicsCompressor(逐樣本)macOS 上為 scalar libsystem_m與同頁 FFT 不一致
Web Audio FFT/向量運算/biquad 濾波器macOS 上為 Accelerate 框架與 compressor 不一致

攤開來看,分裂只發生在一個位置。這把嫌疑範圍從「整個 V8 的數學層」收窄到「單一函式,在某個版本被單獨改了實作方式」,接下來要找的不是一整套機制,而是一個具體的改動。

真正原因:一個 commit 把 tanh 換成呼叫平台 libm

答案就在版本紀錄裡。V8 的 commit c1486295ae5Math.tanh 內建的 fdlibm port 換成了 std::tanh——一個直接讀取宿主 libm 的呼叫。這個改動隨 V8 14.8.57 出貨,對應到 Chrome 148。Chrome 147 以前的版本不會洩漏,148、149、150 都會。在此之前,tanh 和其他數學函式走同一條路:V8 內建一份跨平台一致的近似實作;換掉之後,它獨自繞過了這層抽象,把問題丟給作業系統自己的 libm 去算。

fdlibm 是一套流傳很廣的自由軟體數學函式庫,長年被許多程式語言的執行環境當作「行為固定、不受作業系統影響」的基準實作使用。把 tanh 從「用 fdlibm 這份固定原始碼算」換成「呼叫 std::tanh 這個由平台 C 標準函式庫提供的符號」,本質上是把計算責任從「V8 自己維護的一份程式碼」移交給「作業系統各自維護的一份程式碼」——維護成本可能因此下降,但跨平台一致性也跟著交了出去。

按按鈕比較 Chrome 148 前後 Math.tanh 的呼叫路徑 · 一條線變三條線

V8 呼叫 Math.tanh(x) commit c1486295ae5 / V8 14.8.57 呼叫 std::tanh(Chrome 148 起) V8 內建,不呼叫平台 Linux/glibc 0.6640367702678491 macOS/libsystem_m 0.664036770267849 Windows/UCRT 0.6640367702678489 V8 內建 fdlibm port 三平台輸出同一份 bit
預設畫面是 Chrome 148 起的現況:一條呼叫路徑分岔成三個平台各自的答案。按鈕(桌面版)可切回 148 之前的單一路徑對照。

每一家 libm 對「怎麼逼近 tanh」都有自己的答案——哪一段用哪一個 minimax 多項式、係數取到幾位、怎麼做範圍歸約,各家互不相讓。同一個輸入交給三套不同的近似多項式,在 IEEE 754 沒有強制正確捨入的前提下,尾端一兩位不對齊是這套機制的自然結果,不是誰的 bug,也不是硬體壞了,只是三個各自獨立、各自合法的答案而已。

洩漏面不只一個:CSS 三角函式與 Web Audio 的另外兩條支線

Math.tanh 這一個洩漏面定位出來之後,順著同一個問題往下問:「還有哪裡也直接呼叫 host libm?」答案是至少還有兩處,而且都跟 Math.tanh 完全獨立。CSS 裡能寫進 calc() 的三角函式——sin()cos()atan2() 等——並不共用 Math.sin 的程式碼。排版引擎把角度化簡成度數之後,直接呼叫平台的 std::sin,結果是七個 CSS 三角函式全部會洩漏,一個都沒漏掉。

CSS 的三角函式是晚近才加進規格的能力,排版引擎在實作時,直接呼叫作業系統既有的 std::sinstd::cos,很可能只是工程上最省事的路——不用重新在瀏覽器裡再打包一份數學函式庫,可以少維護一條程式碼路徑。省下來的維護成本,換來的是一個新的洩漏面,而且範圍比 Math.tanh 還大:一次涵蓋七個函式。

點名詞看它實際呼叫哪一套 libm · 三個洩漏面

三個洩漏面各自繞過不同的抽象層: Math.tanh V8 自 Chrome 148(commit c1486295ae5)起呼叫平台 std::tanh,Linux 讀 glibc、macOS 讀 libsystem_m、Windows 讀 UCRT。 、CSS 三角函式 sin()、cos()、atan2() 等七個函式,Blink 排版引擎把角度化簡後直接呼叫平台 std::sin,與 Math.sin 完全獨立,全部會洩漏。 、以及 Web Audio 的 DynamicsCompressor/FFT macOS 上壓縮器逐樣本運算用 scalar libsystem_m,FFT、向量運算、biquad 濾波器用 Accelerate 框架,兩者對同一批輸入不一致。 ,各自因為不同的工程理由,直接把計算交給了作業系統。

第三條支線在 Web Audio。macOS 上的 DynamicsCompressor 逐樣本運算用的是 scalar libsystem_m——跟 Math.tanh、CSS 三角函式背後同一套函式庫;但 FFT、向量運算、biquad 濾波器走的是 Accelerate 框架裡向量化過的 vvsinvvtanh 之類函式,是完全不同的程式碼路徑。這兩套實作對同一批輸入並不一致——量測一百萬個輸入,不一致的比例依函式不同,落在 10% 到 89% 之間。同一個音訊圖裡,壓縮器和 FFT 用的其實是兩套不同的三角函式實作,彼此之間的落差本身就足夠當成又一枚指紋。

10% 到 89% 這個區間看起來像是雜訊,但對指紋辨識來說,不一致的比例本身也是一項可利用的訊號——挑一個分歧比例接近極端值的函式,兩套實作在多數輸入上就會給出不同答案,不需要太多次探測就能區分。反過來,如果挑到分歧比例接近零的函式,兩套實作幾乎在所有輸入上都一致,自然也就不構成指紋。

Scrapfly 怎麼把三套 libm 蓋成 bit-for-bit

找到三個洩漏面之後,剩下的問題是怎麼把它們消除。Scrapfly 的爬蟲瀏覽器 Scrapium 選擇的路不是隱藏這些函式,而是把每一家目標系統的 libm 逆向出來,重建到位元對位元一致——先把目標 libm 的 minimax 係數、指數表、範圍歸約常數整套抽出來,轉寫成可移植的 C 程式碼,目標是「每一個位元都對上」;編譯時關掉 FMA 融合(-ffp-contract=off),避免編譯器自己塞進一個沒被要求過的融合乘加,悄悄改變捨入結果。

FMA(融合乘加)本身不是問題,問題是編譯器有沒有「主動加料」。如果原始碼寫的是兩個獨立運算,但編譯器在背後偷偷融合成一次帶更高中間精度的運算,捨入行為就會跟著變,即使程式碼一個字都沒改。Scrapfly 選擇整條編譯管線都關掉這個自動融合,確保重建出來的近似多項式,捨入行為完全照著原始碼寫的順序走,不多也不少。

Windows 這個選擇背後的邏輯不難理解:UCRT 的原始碼並不公開,逆向出一套行為完全一致的替代實作,成本遠高於直接把真正的系統檔案映射進來執行——與其猜測係數,不如直接借用真正的答案。唯一要解決的技術門檻是呼叫慣例:Windows 上的 DLL 匯出函式預期用 ms_abi 傳參數,寫錯這一步,程式會直接崩潰,而不是算出錯誤答案。

技術目標為什麼需要
逆向 minimax 係數/指數表/範圍歸約常數,轉寫成可移植 CLinux/macOS/Windows讓重建的近似多項式與目標 libm 每個位元都對上
-ffp-contract=off整條編譯管線避免編譯器自行融合乘加,悄悄改變捨入結果
映射真正的 ucrtbase.dll,以 ms_abi 呼叫其匯出函式WindowsUCRT 原始碼不公開,直接借用真正的系統實作而非猜測係數
每版 871,000 筆輸入回歸,對照真 Mac 硬體與真 Mac Chrome全平台驗證涵蓋密集網格、區間邊界、次正常數、無窮大、NaN 等所有分支

驗證用的兩層基準也值得說清楚:第一層是拿一台貨真價實的 Mac,同時跑 scalar 與 Accelerate 兩條路徑算出同一批輸入,才能精確知道兩者到底在哪些輸入上分歧、分歧多少;第二層是拿一台貨真價實的 Mac 版 Chrome,透過瀏覽器的除錯協定,直接量出 Math.tanh 與每一個 CSS 三角函式在真實環境下的答案,當成最終要比對的正確答案。兩層基準疊在一起,才能確保重建出來的近似不只是「看起來差不多」,而是真的位元對位元一致——結果是 Scrapium 在 Math.tanh 上做到與真正的 Mac 版 Chrome 位元對位元一致。

這個模式值得記住的原因,不只是 tanh 本身。任何一個從「瀏覽器自己打包的實作」改成「直接呼叫作業系統」的 API,理論上都可能重演同一齣戲——差別只在於有沒有人願意花時間,把三個平台的字串一位一位攤開來比對。

把整條調查線收回來看,真正值得記住的不是「tanh 洩漏了」這一個孤立事實,而是它背後那句更一般的話:任何一段從「內建、跨平台一致」換成「呼叫宿主函式庫」的程式碼,只要 IEEE 754 沒有強制正確捨入,就自動變成一枚指紋。問題不在浮點數本身出了什麼錯,而在於背後究竟是哪一家的近似多項式在跑。

下次遇到:同一段程式碼在不同系統上跑出不一樣的結果,先別急著懷疑編譯器或硬體亂數,去查它是不是剛從「內建函式庫」換成了「呼叫 host 的 libm」。這類換法通常不會出現在顯眼的 release note 裡,卻是最乾淨、最難隱藏的指紋來源。