在指纹浏览器的开发中,navigator 对象是兵家必争之地。风控系统对其属性的校验极其严苛,而 99% 的爬虫工程师和劣质指纹浏览器,都死在了对属性描述符的粗暴处理上。
试想一个最常见的场景:为了绕过检测,你用 JS 注入了一段代码:
javascript
Object.defineProperty(navigator, 'platform', { get: () => 'MacIntel' });
你以为你赢了,但风控系统只需一行代码就能让你原形毕露:
javascript
Object.getOwnPropertyDescriptor(Navigator.prototype, 'platform');
在真实的 Chrome 环境中,这个原生属性的描述符是 {value: undefined, writable: true, enumerable: true, configurable: true}(注意:现代浏览器将其定义在原型链上,getter 在更底层)。而你的 Object.defineProperty 动作,已经篡改了原本的属性特征,甚至留下了你覆写时的函数堆栈。
真正的反检测,必须斩断前端 JS 层的干预,直捣黄龙------在 Chromium 的 C++ 源码中,重写属性的数据源。
本文将摒弃水话,直接深入 third_party/blink/renderer/core/frame/,手把手拆解如何从底层无痕重写 UA、Platform 和 Language。
一、 核心认知:V8 与 Blink 的属性绑定真相
在动手改 C++ 代码前,必须理解 JS 中的 navigator.platform 是怎么来的。
Chromium 使用 Web IDL 来定义暴露给 JS 的接口。在 navigator.idl 文件中,你会看到:
webidl
interface Navigator {
readonly attribute DOMString platform;
};
编译时,Chromium 的代码生成器会根据这个 IDL,自动生成 V8 绑定代码(v8_navigator.cc)。这段代码会在 V8 引擎的 Navigator.prototype 上挂载一个名为 platform 的访问器。当 JS 读取该属性时,V8 会调用底层的 Blink C++ 方法 Navigator::platform()。
关键点 :IDL 生成的绑定代码是"白名单"式的,它严格控制了属性的 writable、enumerable 和 configurable 特征,使其与 Web 标准完全一致。
因此,我们的策略是:绝不碰 V8 绑定层,只修改 Blink 层的 C++ 实现方法。 这样,V8 暴露给 JS 的描述符依然是原生的,但返回的值已经被我们偷天换日。
二、 破局第一步:配置注入架构
Renderer(渲染)进程处于沙箱中,无法读取本地文件。所以伪装的值必须由 Browser(主)进程传入。
最稳妥、最防时序攻击的架构是:命令行参数注入。
- Browser 进程启动时 :读取指纹配置文件,将伪装的 UA、Platform 等编码为字符串,通过
--fingerprint-params命令行参数传递给即将启动的 Renderer 进程。 - Renderer 进程初始化时 :在极早期的生命周期(如
RendererMain入口),解析命令行参数,将配置存入一个全局的 C++ 单例FingerprintConfig中。
这保证了当 JS 第一次执行时,配置已经在内存中就绪。
三、 底层重写三大核心属性
进入核心目录:third_party/blink/renderer/core/frame/
1. 斩断 Platform(操作系统平台)
这是风控检查操作系统一致性的第一道关卡。如果你声称是 Mac,但 Platform 返回 Win32,直接封号。
打开 navigator.cc,找到 Navigator::platform() 方法。
原始代码逻辑(简化):
cpp
String Navigator::platform() const {
// 可能会调用系统 API 获取真实的操作系统宏
return String(PLATFORM);
}
底层重写逻辑:
cpp
String Navigator::platform() const {
// 优先从全局指纹配置单例中获取
const auto& fp_config = FingerprintConfig::GetInstance();
if (fp_config->HasOverride("platform")) {
return fp_config->GetString("platform");
}
// 兜底:返回真实值
return String(PLATFORM);
}
效果 :JS 执行 navigator.platform,V8 调用此 C++ 方法,返回 "MacIntel"。描述符完全原生,没有任何 JS 污染。
2. 斩断 UserAgent(用户代理)
UA 伪装的难点不在于改写本身,而在于全网一致性 。很多劣质浏览器只改了 navigator.userAgent,却忘了 HTTP 请求头中的 UA,导致瞬间暴露。
我们需要同时修改 JS 层和网络层。
A. JS 层重写
同样在 navigator.cc 中:
cpp
String Navigator::userAgent() const {
const auto& fp_config = FingerprintConfig::GetInstance();
if (fp_config->HasOverride("userAgent")) {
return fp_config->GetString("userAgent");
}
return GetFrame()->Loader().UserAgent();
}
B. 网络层/HTTP 头重写
HTTP 请求头中的 User-Agent 是由 Browser 进程的网络栈填写的。我们必须在 Browser 进程中拦截。
精准坐标 :content/browser/loader/ 或网络栈的 Delegate 层。
在构建 HTTP 请求时,拦截并替换 HttpRequestHeaders 中的 User-Agent 字段。这确保了 JS 环境和网络底层发出的 UA 绝对一致。
C. 高熵 Client Hints(现代风控的杀手锏)
现代风控不再只看传统 UA,而是通过 navigator.userAgentData.getHighEntropyValues() 获取底层架构信息。这是最容易被忽略的致命点。
精准坐标 :third_party/blink/renderer/core/frame/navigator_ua_data.idl 及对应实现。
你需要修改 NavigatorUAData::GetHighEntropyValues 的回调逻辑,确保返回的 platform、platformVersion、architecture、model 等字段与你伪装的 UA 强绑定,绝不能出现 UA 是 Windows,但 architecture 返回 arm 的逻辑悖论。
3. 斩断 Language & 时区(时空一致性)
语言和时区必须与代理 IP 的地理位置强绑定,否则风控的时空关联杀伤链会立刻触发。
A. 语言重写
打开 navigator.cc:
cpp
Vector<String> Navigator::languages() {
const auto& fp_config = FingerprintConfig::GetInstance();
if (fp_config->HasOverride("languages")) {
return fp_config->GetStringList("languages");
}
// 原始逻辑:返回系统语言
}
致命陷阱:Accept-Language HTTP 头 。
与 UA 一样,只改 JS 层是徒劳的。必须在 Browser 进程的网络栈中,强制覆写每个请求的 Accept-Language 头,使其与 navigator.language 完全对齐。
B. 时区重写
时区是 JS 环境的底层依赖,不能简单改返回值,否则会导致 new Date() 的计算结果与预期不符。
底层重写逻辑 :
Chromium 的 V8 引擎在初始化时,会从系统获取默认时区并缓存。我们需要在 V8 初始化之前,将环境变量 TZ 设置为指纹配置中的时区(如 America/New_York)。
精准坐标 :content/renderer/renderer_main.cc。
在 Renderer 进程的入口函数最顶部:
cpp
int RendererMain(const MainFunctionParams& parameters) {
// 最先设置时区,确保 V8 初始化时读取到伪装值
const auto& fp_config = FingerprintConfig::GetInstance();
if (fp_config->HasOverride("timezone")) {
setenv("TZ", fp_config->GetString("timezone").utf8().c_str(), 1);
tzset(); // 更新 C 库的时区变量
}
// ... 原始的 Renderer 初始化逻辑
}
这种做法利用了操作系统级别的时区机制,V8 的 Intl.DateTimeFormat 和 new Date().getTimezoneOffset() 都会基于此环境变量计算,实现了物理级的时区伪装,且对 Intl API 的底层逻辑没有任何破坏。
四、 防御升级:对抗属性枚举与反射检测
高级风控会尝试检测属性是否被"动过"。在 C++ 层修改数据源已经规避了大部分检测,但仍需防范一些极端的探测手段。
1. iframe 隔离检测
风控会创建一个隐藏的 <iframe>,试图在其中获取"未被污染"的原生 navigator 属性。如果你用 JS Hook,由于作用域问题,iframe 往往会暴露真实值。
底层防御 :由于我们修改的是 C++ 渲染引擎的实现类,同一个 Renderer 进程下的所有 iframe(无论跨域与否)在实例化 Navigator 对象时,调用的都是同一个被修改的 C++ 方法。所以,iframe 检测在 C++ 层修改面前完全无效。
2. toString() 与堆栈追踪
风控可能会覆写 Object.getOwnPropertyDescriptor,然后检查 getter 的 toString() 输出,或者抓取执行堆栈看是否有可疑的匿名函数。
底层防御 :我们的修改发生在 V8 绑定层之下的 Blink 层。JS 拿到的 getter 函数,其内部实现是一个指向 C++ 函数的指针。toString() 输出永远是 function get platform() { [native code] },堆栈追踪中绝不会有任何 JS 脚本的影子。
3. Proxy 代理对象嗅探
风控有时会检查 Navigator.prototype 是否是一个被代理的对象。
底层防御 :我们从未在 JS 层替换或代理任何对象,原型链依然指向原始的 Navigator.prototype。
五、 避坑实录:底层重写的暗礁
1. 执行时序的拼刺刀
如果你采用 Mojo IPC 从 Browser 进程向 Renderer 进程同步配置,极有可能在页面执行第一行 JS 时,IPC 通道还未建立,导致读取到真实值。
破局:前文提到的命令行参数注入是唯一稳妥的方案。它在进程创建的瞬间就已经就绪,不存在时序竞争。
2. Worker 线程的幽灵
主线程的 navigator 被改了,但 Web Worker 里的 navigator 暴露了真实信息。
破局 :Worker 线程同样运行在 Renderer 进程中,它们共享同一套 Blink 引擎实现。只要我们修改的是底层的 C++ 数据源(如 Navigator::platform()),Worker 中的调用也会自动走修改后的逻辑。但需特别注意 Service Worker,它有时会有独立的上下文初始化流程,需确保配置注入覆盖到所有上下文类型。
3. 内存泄漏
如果你在 C++ 中使用 std::map 或类似结构存储指纹配置,且没有正确管理生命周期,极易在 Renderer 进程(极其脆弱)中引发内存泄漏或 UAF(Use-After-Free)崩溃。
破局 :使用 Blink 体系内的智能指针和容器(如 HeapHashMap),或者使用纯静态的 POD 类型存储配置,避免复杂的 C++ 对象生命周期管理。
结语 :斩断 navigator 前端,本质上是将伪装的阵地从"容易被看穿的 JS 脚本",撤退到"风控无法触及的 C++ 内核"。当你的 platform、userAgent、language 都是由 Blink 引擎的底层方法计算得出,拥有完美的原生描述符和执行堆栈,风控系统的前端探针就成了瞎子。
但这只是基础。风控如果发现你的 UA 是 Mac,但你的显卡渲染出来的 Canvas 指纹却是一块廉价的集成显卡,或者你的字体列表里全是 Windows 独占字体,这种跨维度的逻辑悖论,依然会触发秒杀。