把流量从阿姆斯特丹绕一圈,Google 就会用荷兰语向你问好。几乎每个用过 VPN 的人都碰到过这个恼人的现象,然后耸耸肩,把它归为 VPN 的怪毛病。这不是怪毛病。这正是本文要讲的那个泄露,只是方向反了过来。
那个请求自己就前后矛盾。数据包来自一个荷兰 IP 地址;请求头却写明了你真正阅读的语言:Accept-Language: en-US,en;q=0.9。在挑选界面语言这件事上,Google 相信的是 IP。而结账页面上的欺诈脚本会把同一个比对反过来跑:它检查语言和 IP 是否对得上,然后把答案记进你的风险评分。本页上的小工具此刻就在对你的浏览器跑这项检查。
Accept-Language:每个请求都带的请求头
无论你的浏览器向服务器要什么——页面、图片还是字体——它都会附上 Accept-Language 这个 HTTP 请求头。它存在的意义,是让服务器能挑一种你看得懂的语言;它的内容来自浏览器设置里的语言列表,而这份列表的初始值,早在浏览器安装时就由操作系统的区域设置定好了。q=0.9 这种记法给列表排出了优先级:这台浏览器最偏好美式英语,普通英语紧随其后。完整的语法规则见 MDN 的 Accept-Language 页面。
这个请求头就在你发出的第一个请求里。不用等某个脚本加载,也不用等某个 cookie 落地。服务器读到它的同一瞬间,也读到了数据包上的源 IP 地址:
198.51.100.7 地理位置数据库 荷兰阿姆斯特丹GET /checkout HTTP/1.1 Host: shop.example User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ... Accept-Language: en-US,en;q=0.9 ◄ 浏览器自己的语言列表
JavaScript 通过 navigator.language 拿到同样的信息,通过 navigator.languages 拿到完整列表,全程没有任何权限弹窗。注意它和时钟的区别:脚本得先跑起来,才能 问出你的时区;而你的语言在页面还没有任何脚本可跑之前就已经送到了。
为什么 VPN 改不了你的浏览器语言
在这件事上,VPN 恰恰无能为力。隧道负责搬运你的数据包,不负责翻译它们。浏览器写下什么就送达什么,包括那个写着“我的用户读英语,不读荷兰语”的请求头。至于改写它,根本无从谈起:在任何 HTTPS 网站上,这个请求头都封在你的浏览器与网站之间的 TLS 会话里传输,VPN 打不开这层加密。
不匹配能证明什么,又不能证明什么
语言是个软信号,比时区还软。对操作系统而言,英语是最接近“出厂设置”的语言,所以阿姆斯特丹 IP 配上 en-US,单看不会让任何人皱眉。比利时有三种官方语言,瑞士有四种。外派人员和远程工作者读的语言和邻居不一样,天经地义。任何一套认真的检测系统都清楚这些,所以语言不匹配只会轻轻推一下评分,仅此而已。
但这个信号有一个尖锐的方向。荷兰出口 IP 背后一台偏好 pt-BR 的浏览器,可没有英语那种模糊空间:它说明键盘前的这个人读巴西葡萄牙语,而荷兰的 IP 地址要多少有多少,把葡萄牙语排在第一位的家庭却寥寥无几。再叠上一个数据中心 IP,和一只仍设在 America/Sao_Paulo 的时钟,那些声称来自阿姆斯特丹的数据包就彻底输掉了这场争辩:
nl-NL, nl+ 阿姆斯特丹 IP一致 两者讲的是同一个故事。没什么可扣分的。en-US, en+ 阿姆斯特丹 IP含糊 英语浏览器遍地都是。单独看是个弱信号,但当其他信号叠上来时,它也会被计入。pt-BR, pt+ 阿姆斯特丹 IP不匹配 浏览器指向巴西,数据包却声称在荷兰。这下你变得有意思了。- 请求头:
nl-NL+ JavaScript:pt-BR检测到伪装 浏览器和自己吵了起来,而除了蓄意篡改,几乎没有什么能造成这种局面。
还有第二重更安静的代价。你那份列表的确切构成——哪些语言、按什么顺序、配什么 q 值——本身就是指纹材料。EFF 的 Panopticlick 研究 测得 Accept 系列请求头约携带六比特的身份信息,Accept-Language 指纹识别正是靠这一点成立的:en-US,en;q=0.9 能藏进人群;nl-BE,fr-BE;q=0.9,en;q=0.8,de;q=0.7 几乎就是一枚签名。作为对比,你的字体列表泄露的比特数是它的两倍多——但字体得靠脚本去测量,而这个请求头在每一次请求里都主动送上门。
浏览器厂商对此心知肚明。正因为如此,Safari 只发送你排第一的那一种语言;Chrome 的工程师提出了 把这个请求头裁剪到只剩一种语言的提案;Brave 则会 按站点刻意模糊自己的回答。不过这些对不匹配问题都无济于事:裁剪之后幸存下来的那一种语言,仍然是你的语言。
为什么一开 VPN,Google 就说错语言
回到那个说荷兰语的 Google。既然浏览器在每个请求里都客客气气地报上了自己的语言,为什么你一连上 VPN,网站就对它视而不见?因为它们更信任 IP 地理定位,而且远在你用上 VPN 之前就已如此。Google 在 2017 年的一则公告 里把这话挑明了:从那时起,它不再用 google.nl 这类域名来区分国家版本,而是检测到什么位置,就端出哪一版。请求头陈述的是偏好;IP 被当作事实。在 Google 首页上让你恼火的这套优先次序,正是你的请求头和 IP 不一致时欺诈系统套用的那一套:IP 获胜,你的请求头则从“偏好”被降级成“证据”。
如果你真正想解决的只是这个烦恼,那答案在账号设置里,而不在 VPN 设置里:在 Google 和多数大型网站上,登录后的语言偏好通常能压过 IP 猜测。但这个泄露本身没有任何设置页能修,因为泄露的就是请求头本身。
又见半吊子伪装的陷阱
读过时区那篇文章的人已经见识过这个陷阱。它在这里长得一模一样,还更容易踩:语言存在于两个地方——网络上的 Accept-Language 请求头,和 JavaScript 引擎里的 navigator.languages。在正常的浏览器里,它们是同一项设置的两个侧面,永远一致。而造假的工具往往顾了一头,忘了另一头。
伪装扩展大多只覆盖 JavaScript 这一侧,因为那是扩展伸手就够得着的地方;你的请求头则继续向每一台服务器坦白真相。改写请求头的代理正好相反,任由 navigator.languages 向每个来问的脚本报出你的真实列表。无论哪一种,浏览器都开始自相矛盾,而这种矛盾在检测系统眼里比原本的不匹配值钱得多:普通 VPN 用户成天制造不匹配,能制造出自相矛盾的却只有蓄意篡改。此外,被覆盖过的 navigator.languages getter,在脚本查看它的源码时不会再报出 [native code],这正是 我们的机器人检测文章 花了一整节来讲的破绽。
唯一大规模行得通的伪装是最无聊的那一种:在默认英文配置下,Tor Browser 对所有人都回答 en-US,en;q=0.5,靠着让你和其他每一个 Tor 用户长得一模一样来藏起你的语言。这笔交易——用“一眼就能看出是 Tor”换来保护——正是那些半吊子扩展假装能提供的东西的诚实版本。
到底该怎么做
- 先跑一遍检查。 本页上的小工具此刻就在拿你浏览器的语言和 IP 所在国家做比对,用的正是欺诈脚本那套“这个国家该有哪些语言”的预期。请开着 VPN 测,因为你的风险评分就是基于这套配置算出来的;然后接着做一次 完整浏览器扫描,把相邻的那几项检查一并跑完:时区、WebRTC、指纹。
- 让你的语言跟着 VPN 走。 干净的修法和时区那篇的建议如出一辙:把声明变成真的。把出口国家的语言加进浏览器的语言列表——Chrome 在
chrome://settings/languages,Firefox 在“首选语言”里——愿意的话尽管把母语留在旁边:多数系统跑的检查是“有没有一种语言对得上”,而不是“是不是每种都对得上”。只是务必在浏览器自己的设置里改,别用扩展,这样请求头和 JavaScript 才会一起变。 - 如果你运营着多个身份,这就不是设置页面能干的活了。 每个配置文件的语言、时区和 IP 都得讲同一个故事,而且每一次会话都得如此;逐个身份手改设置的做法,一碰上真实的工作量就会散架。Incogniton 会按每个配置文件的代理来设定它的语言,请求头和 JavaScript 一步到位,让浏览器永远不会和自己的数据包吵起来。
你的浏览器刚刚已经把你读什么语言告诉了你访问的上一台服务器。上面的小工具会告诉你,你的 IP 有没有为这句话背书。