在反爬虫与风控的对抗中,爬虫工程师常有一种错觉:我用了最新版的 Chrome,代理 IP 也很干净,为什么一访问就被拦截?原因在于,你的浏览器在打开网页的那一瞬间,就已经在风控的审视下"裸奔"了。
风控系统不需要你输入身份证号,它只通过一段几十 KB 的 JavaScript 代码,就能在毫秒级内给你的浏览器画出一幅精确到像素、时区和硬件毛细血管的"数字画像"。
本文将拆解网站是如何一步步读取你的浏览器指纹的。只有极致了解对方如何侦察,才能在指纹浏览器开发中做到精准反制。
一、 指纹采集的先锋:基础环境与系统特征
风控 JS 下发的第一波请求,是不需要任何计算复杂度的"明文特征",它们挂在 navigator 和 screen 对象上,是一切指纹关联的基石。
1. UserAgent 与高熵特征
不仅读取 navigator.userAgent 字符串,现代风控更看重 navigator.userAgentData(Chrome 90+ 引入的 Client Hints API)。
javascript
// 风控代码示例:获取高熵特征,剥离伪造的 UA
navigator.userAgentData.getHighEntropyValues([
"platform", "platformVersion", "architecture", "model", "uaFullVersion"
]).then(ua => {
// 这里能拿到真实的操作系统架构(x86/arm)、Windows具体版本号
// 如果你用JS强行改了UA字符串,但没改底层Client Hints,瞬间暴露
});
2. 硬件并发与内存
这是判断设备算力的重要指标,也是区分服务器/模拟器与真实PC的关键。
javascript
let cores = navigator.hardwareConcurrency || 0; // CPU逻辑核心数
let memory = navigator.deviceMemory || 0; // 设备内存GB(受隐私策略限制,可能返回近似值)
// 真实用户:cores通常是4,8,16;memory是4,8,16
// 服务器/爬虫:cores动辄64,memory是32+,或者返回undefined(无头浏览器常见)
3. 屏幕与色彩深度
屏幕分辨率本身容易伪造,但风控看的是"组合逻辑"。
javascript
let screenInfo = {
w: screen.width, // 屏幕物理宽度
h: screen.height, // 屏幕物理高度
aw: screen.availWidth, // 可用宽度(去除任务栏)
ah: screen.availHeight, // 可用高度
cd: screen.colorDepth, // 色深,通常为24或32
dpr: window.devicePixelRatio // 设备像素比,Retina屏为2或3
};
// 矛盾检测:如果 screen.width=1920, 但 availHeight=1080(没有给任务栏留空间),大概率是虚拟屏幕
// 矛盾检测:如果 dpr=1,但声称是 MacBook Retina 设备,直接判定伪造
4. 时区与语言环境
这是风控进行"时空一致性"校验的起点,与代理 IP 的地理位置强绑定。
javascript
let timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; // 如 "Asia/Shanghai"
let locale = navigator.language; // 如 "zh-CN"
// 致命矛盾:IP归属地是美国,timezone却是Asia/Shanghai,语言是zh-CN,90%概率是爬虫
二、 深入硬件:高级渲染指纹
基础特征容易伪装,但 GPU 和声卡在执行渲染和计算时的物理微小差异,是无法通过简单修改 JS 变量伪造的。这是风控最核心的"杀招"。
1. Canvas 指纹:GPU 的独特笔迹
Canvas 指纹的原理是:让浏览器在画布上绘制特定图形和文字,由于 GPU 型号、驱动版本、底层渲染引擎(如 Skia/DirectWrite)的抗锯齿算法不同,最终生成的像素数据存在微小差异。
风控实战代码解析:
javascript
function getCanvasFingerprint() {
let canvas = document.createElement('canvas');
let ctx = canvas.getContext('2d');
// 绘制文本:不同系统的字体渲染引擎(Mac/CoreText vs Win/GDI)差异极大
ctx.textBaseline = "top";
ctx.font = "14px 'Arial'";
ctx.fillStyle = "#f60";
ctx.fillRect(125, 1, 62, 20);
ctx.fillStyle = "#069";
ctx.fillText("Hello, world! 🤖", 2, 15); // 加入Emoji,测试色彩支持
// 绘制图形:测试混合模式与抗锯齿
ctx.fillStyle = "rgba(102, 204, 0, 0.7)";
ctx.beginPath();
ctx.arc(50, 50, 50, 0, Math.PI * 2, true);
ctx.fill();
// 提取像素数据并计算哈希
let dataStr = canvas.toDataURL();
// 风控不会把几MB的图片传回服务器,而是在本地计算哈希
let hash = murmurhash3(dataStr);
return hash;
}
反制难点 :如果你用 JS Hook 拦截 toDataURL 返回一个假哈希,风控可以通过读取 getImageData 检查特定像素点的 RGBA 值是否与哈希匹配;如果你只改结果不改渲染,风控可以测量绘制操作耗费的时间(真实 GPU 绘制耗时与 CPU 模拟耗时差异巨大)。真正的指纹浏览器必须在 C++ 层(Skia 引擎)对输出的像素矩阵注入合法噪声。
2. WebGL 指纹:显卡的身份证
WebGL 提供了比 Canvas 更底层的 GPU 交互接口。风控不仅看渲染结果,更看 GPU 的身份参数。
javascript
function getWebGLFingerprint() {
let canvas = document.createElement('canvas');
let gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
let debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
let vendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL); // 显卡厂商
let renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL); // 显卡型号
// 典型的暴露特征:
// VMware/Gallium 3D -> 虚拟机
// SwiftShader -> 无头模式/软件渲染
// ANGLE (Intel/NVIDIA...) -> 真实PC
// 此外,风控还会枚举WebGL支持的所有扩展列表(gl.getSupportedExtensions)
// 不同的GPU和驱动支持的标准扩展集合不同,这又是一个高维特征
}
3. AudioContext 指纹:声卡的回声
不播放任何声音,通过让浏览器处理音频信号,捕捉底层音频硬件和驱动处理的微小差异。
javascript
function getAudioFingerprint() {
let context = new (window.AudioContext || window.webkitAudioContext)();
let oscillator = context.createOscillator();
let analyser = context.createAnalyser();
let gain = context.createGain();
let scriptProcessor = context.createScriptProcessor(4096, 1, 1);
oscillator.type = 'triangle';
oscillator.frequency.setValueAtTime(10000, context.currentTime);
gain.gain.setValueAtTime(0, context.currentTime); // 静音,用户听不见
// 连接节点并渲染
oscillator.connect(gain);
gain.connect(analyser);
analyser.connect(scriptProcessor);
scriptProcessor.connect(context.destination);
// 获取处理后的音频波形数据并计算哈希
// 不同声卡对浮点数的计算精度、压缩算法不同,导致最终波形数据的极微小差异
}
三、 系统级探测:字体与多媒体
1. 字体枚举指纹
系统安装的字体列表是高度个性化的,尤其是中文字体。风控通过测量特定字符在不同预设字体下的渲染宽度,来判断该字体是否存在。
javascript
function detectFonts(fontList) {
let baseFonts = ['monospace', 'sans-serif', 'serif'];
let testString = "mmmmmmmmmmlli";
let testSize = '72px';
let h = document.getElementsByTagName("body")[0];
let s = document.createElement("span");
s.style.fontSize = testSize;
s.innerHTML = testString;
let defaultWidths = {};
// 获取基础字体的宽度
for (let font of baseFonts) {
s.style.fontFamily = font;
h.appendChild(s);
defaultWidths[font] = s.offsetWidth;
h.removeChild(s);
}
let detectedFonts = [];
for (let font of fontList) {
let found = false;
for (let baseFont of baseFonts) {
s.style.fontFamily = `'${font}', ${baseFont}`;
h.appendChild(s);
// 如果宽度与基础字体不同,说明该字体被系统成功加载并渲染
if (s.offsetWidth !== defaultWidths[baseFont]) {
found = true;
break;
}
h.removeChild(s);
}
if (found) detectedFonts.push(font);
}
return detectedFonts;
}
// 矛盾检测:声称是 Mac OS,却检测出微软雅黑和宋体,直接封杀
2. 媒体设备探测
摄像头、麦克风的存在与否及数量,也是环境真实性的佐证。
javascript
navigator.mediaDevices.enumerateDevices().then(devices => {
let audioOutput = devices.filter(d => d.kind === 'audiooutput').length;
let audioInput = devices.filter(d => d.kind === 'audioinput').length;
let videoInput = devices.filter(d => d.kind === 'videoinput').length;
// 无头浏览器或服务器通常返回 0 个设备
});
四、 反侦察与对抗:风控如何识破 JS Hook
爬虫工程师常说:"我用了 Vue/React 的 Proxy,把 navigator 和 Canvas 全拦截了,为什么还是被查?"
因为现代风控拥有极其变态的"反反指纹"机制,专门检测 JS 运行环境是否被污染。
1. 属性描述符检测
原生对象的属性是内置的,其特性与通过 Object.defineProperty 劫持的属性完全不同。
javascript
// 爬虫常见的Hook:
Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
// 风控反制检测:
let descriptor = Object.getOwnPropertyDescriptor(Navigator.prototype, 'webdriver');
if (descriptor && descriptor.get && descriptor.get.name !== 'get webdriver') {
// 原生的 getter 名称是 'get webdriver',如果你Hook了,name会变成 'get'
// 或者 descriptor.configurable 变成了 true(原生一般是 false)
return "环境被篡改!";
}
2. 原型链与 toString 检测
原生函数(如 toDataURL)转为字符串时,应输出 [native code]。
javascript
// 爬虫覆盖了 canvas.toDataURL
canvas.toDataURL = function() { return fakeHash; };
// 风控检测
if (canvas.toDataURL.toString().indexOf('[native code]') === -1) {
return "函数被覆写!";
}
// 更狠的检测:直接去 iframe 中获取纯净的函数来对比
3. 降维隔离检测
风控 JS 会创建一个隐藏的、脱离当前文档流的 <iframe>,获取 iframe 内部的 navigator 和 canvas 等对象,与当前页面的对象进行对比。
javascript
let iframe = document.createElement('iframe');
iframe.style.display = 'none';
document.body.appendChild(iframe);
let realNavigator = iframe.contentWindow.navigator;
// 如果当前页面的 navigator.webdriver !== realNavigator.webdriver
// 说明你在当前页面做了全局 Hook,但 iframe 里的原生对象出卖了你
4. CDP 与 WebDriver 铁证
这是 Selenium/Playwright 无法根治的绝症。
javascript
// 检测1:最基础的 webdriver 标志
navigator.webdriver === true; // CDP控制下默认为true
// 检测2:Chrome 特有变量
window.cdc_adoQpoasnfa76pfcZLmcfl_Array; // Chromedriver 注入的变量特征
// 检测3:执行堆栈分析
// 风控触发一个错误,检查 Error().stack
// 如果堆栈中出现 'puppeteer'、'evaluateOnNewDocument' 等字眼,直接击毙
结语 :通过上述拆解,我们可以得出一个残酷的结论:在 JS 层面进行的任何指纹伪装,都是掩耳盗铃。
无论你的 Proxy 写得多优雅,风控总能通过原型链、属性描述符、iframe 隔离和堆栈分析将你扒得底裤不剩。
传统爬虫在 JS 层的 Hook 对抗,已经走进了死胡同。这也正是指纹浏览器必须存在的根本原因。
真正的指纹浏览器,绝不依赖油猴脚本或 JS 注入。它的战场在 C++ 层:
- Canvas/WebGL 伪装:修改 Chromium 的 Skia/ANGLE 引擎,在 GPU 计算完成后、返回给 JS 引擎之前,在内存中直接对像素矩阵注入合法噪声。
- 属性篡改 :直接修改 Blink 引擎的
Navigator.idl和相关 C++ 类的返回值,让navigator.webdriver从编译层面就不存在,属性描述符依然是原生的。 - 环境隔离:利用操作系统的沙箱和命名空间,让每个浏览器实例拥有独立且真实的设备映射。
在风控之眼面前,JS 层的伪装如同纸糊的面具;只有深入内核,重塑骨骼,才能在风控的凝视下隐入尘烟。这也是我们接下来探讨指纹浏览器底层开发的核心方向。