news 2026/10/15 16:23:57

深入解析 puppeteer-extra-plugin-stealth 的 navigator.plugins 伪装:在 Headless 模式重建完整的插件与 MIME 类型环境

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入解析 puppeteer-extra-plugin-stealth 的 navigator.plugins 伪装:在 Headless 模式重建完整的插件与 MIME 类型环境
  • 网页爬虫
  • 浏览器控制
  • 测试

【免费下载链接】puppeteer-extra

💯 Teach puppeteer new tricks through plugins.

项目地址:https://gitcode.com/gh_mirrors/pu/puppeteer-extra
点击查看免费下载

导读

navigator.plugins与navigator.mimeTypes是浏览器指纹检测中最常被探测的一组 API:真实用户使用 headful Chrome 时,这两个属性包含 PDF 查看器、Native Client 等内置插件;而 headless 模式下它们却是空数组,这一差异足以让网站轻松识别自动化浏览器。本文以 puppeteer-extra-plugin-stealth 中的navigator.plugins反检测模块为线索,结合仓库源码与测试用例,完整剖析该模块如何从零构建一套"功能完整、行为逼真且不留篡改痕迹"的MimeTypeArray与PluginArray。读完本文,你将掌握该反检测技术的实现原理、底层 JS Proxy 技巧,以及如何在 puppeteer-extra 中单独或整体启用它。

一、背景:Headless 模式为何暴露空插件列表

1.1 问题的本质

根据 navigator.plugins 模块文档 的说明:

In headless modenavigator.mimeTypesandnavigator.pluginsare empty. This plugin emulates both of these with functional mocks to match regular headful Chrome.

即在 headless 模式下,navigator.mimeTypes和navigator.plugins均为空,而正常的 headful Chrome 中它们包含可枚举的插件与 MIME 类型条目。这组差异是"猫鼠游戏"中极易被捕获的破绽,检测脚本只需检查navigator.plugins.length是否为 0 即可判定浏览器是否为 headless——这一点在仓库的 cat-and-mouse.test.js 中作为检测手段出现:

await test('navigator.plugins empty', _ => { return navigator.plugins.length === 0 })

1.2 为什么不能简单造个假数组

仅仅把数据塞进一个普通 JavaScript 数组远远不够。检测方会通过Array.isArray()、JSON.stringify()、Object.getOwnPropertyNames()、Object.keys()、toString()、Symbol.toStringTag、函数toString()输出、原型链、错误堆栈乃至valueOf()等数十种手段交叉验证。因此该模块的目标是:让伪造出来的对象在行为上"无限逼近"原生 headful Chrome 的对象,而不是表面上有几个数据。

二、模块 API 与使用方式

2.1 核心类 API

文档中暴露的核心类为Plugin,它继承自PuppeteerExtraPlugin,构造函数接受一个可选参数opts(默认{}):

项目说明
类名Plugin
继承PuppeteerExtraPlugin
构造参数opts(可选,默认{})
插件名称stealth/evasions/navigator.plugins
挂载时机onPageCreated,通过evaluateOnNewDocument注入

源码定义位于 index.js:Plugin类通过get name()声明插件标识,并在onPageCreated(page)钩子中利用withUtils(page).evaluateOnNewDocument(...)将注入脚本注册到每个新页面的文档加载前。

2.2 使用方式

该模块是 stealth 插件默认启用的 16 个反检测模块之一(见 stealth 插件源码 中的availableEvasions列表),因此最常见的用法是直接整体启用 stealth:

const puppeteer = require('puppeteer-extra') const StealthPlugin = require('puppeteer-extra-plugin-stealth') puppeteer.use(StealthPlugin()) // 默认启用全部 evasion,含 navigator.plugins

也可以绕过主模块,像独立puppeteer-extra插件那样单独引入本模块(参考 stealth readme 的说明):

const puppeteer = require('puppeteer-extra') puppeteer.use( require('puppeteer-extra-plugin-stealth/evasions/navigator.plugins')() )

甚至可以在整体启用 stealth 后动态剔除该模块:

const pluginStealth = require('puppeteer-extra-plugin-stealth')() pluginStealth.enabledEvasions.delete('navigator.plugins') puppeteer.use(pluginStealth)

三、实现原理:从零构建"可信"的插件体系

整个模块由五个文件协作完成:index.js(主入口)、mimeTypes.js(生成 MimeTypeArray)、plugins.js(生成 PluginArray)、magicArray.js(核心生成器)、functionMocks.js(函数级 Mock)。

3.1 入口流程

onPageCreated的注入脚本按以下顺序执行(对应 index.js):

  1. 头部检测:若'plugins' in navigator && navigator.plugins.length为真,说明当前运行在 headful 环境,立即返回,不做任何改动(避免重复污染)。
  2. 生成数组:用data.json中的静态数据分别生成mimeTypes与plugins两个数组对象。
  3. 交叉引用:Plugin与MimeType互相引用(enabledPlugin属性),逐一建立双向关联。
  4. 原型替换:通过patchNavigator在Object.getPrototypeOf(navigator)上定义mimeTypes与plugins的 getter,而不是直接给navigator加属性——这是防止Object.getOwnPropertyNames(navigator)泄露修改痕迹的关键。
const patchNavigator = (name, value) => utils.replaceProperty(Object.getPrototypeOf(navigator), name, { get() { return value } })

3.2 统一生成器 magicArray

MimeTypeArray与PluginArray结构高度相似,因此 magicArray.js 用同一个generateMagicArray函数生成两者,通过参数区分:

参数生成 MimeTypeArray 时生成 PluginArray 时
protoMimeTypeArray.prototypePluginArray.prototype
itemProtoMimeType.prototypePlugin.prototype
itemMainProptypename

生成过程包含四个层层递进的技术要点:

(1)非枚举属性描述符:defineProp统一用Object.defineProperty设置writable: false, enumerable: false, configurable: true,还原原生对象的属性描述符特征,同时保证JSON.stringify(navigator.mimeTypes)不会输出内部数据。

(2)原生原型造假对象:每个条目通过Object.create(itemProto, descriptor)创建,使navigator.mimeTypes[0].toString()返回'[object MimeType]'、Symbol.toStringTag为'MimeType',从而通过toString/原型链校验。其中Plugin条目还有一个特殊处理:其length属性被硬编码为对应__mimeTypes的长度(navigator.plugins[i].length应等于其关联的 MIME 类型数量),且为非枚举。

(3)Proxy ownKeys 陷阱:原生MimeTypeArray/PluginArray的length属性虽然存在于原型上,但 headful Chrome 执行Object.getOwnPropertyNames(navigator.mimeTypes)时却不会列出它。为复现这种"魔法行为",代码先给length打上configurable: true,再用 Proxy 的ownKeys与getOwnPropertyDescriptor陷阱将其从属性列表中剥除,同时伪造出基于数据的属性名列表(索引 + 类型名,如["0","1","2","3","application/pdf",...])。

(4)函数级重定向:Proxy 的get陷阱将item、namedItem(以及 PluginArray 特有的refresh)调用重定向到自定义 Mock,其余属性正常透传。

3.3 函数级 Mock:复刻原生怪异行为

functionMocks.js 负责item、namedItem、refresh三个方法的仿真:

  • item():复刻原生行为——参数会被尝试转换为整数并作为索引查找;传入非整数(包括字符串)时返回第一个条目;永远不返回undefined,越界返回null;无参数时抛出与原生一致的TypeError("Failed to execute 'item' ...: 1 argument required, but only 0 present.")。
  • namedItem():按itemMainProp(type或name)精确查找,未命中返回null(同样不是undefined),无参数抛出TypeError。
  • refresh():PluginArray 特有的方法,直接返回undefined,模拟原生"什么都不做"的语义。

这些 Mock 通过utils.createProxy创建,内部调用了 _utils/index.js 提供的stripProxyFromErrors(剥离 Proxy 在错误堆栈中的痕迹)与patchToString(让函数toString()输出function item() { [native code] })。

3.4 MIME 类型与插件的交叉引用

文档明确指出:"mimeTypes and plugins cross-reference each other, so it makes sense to do them at the same time."这正是 index.js 中最精妙的部分:

  1. 遍历data.plugins,把每个插件声明关联的 MIME 类型对象按索引挂到插件条目上(plugins[name][index] = mimeTypes[type]),并定义非枚举的命名属性。
  2. 给每个MimeType定义enabledPlugin属性,指向对应插件。
  3. 防循环引用:enabledPlugin的值为new Proxy(plugins[name], {})——用空 Proxy 包装,既保证访问语义正确(如mimeTypes['application/pdf'].enabledPlugin能取到插件),又避免JSON.stringify(navigator.plugins)因循环引用而崩溃。唯一的例外是application/x-pnacl:它与application/x-nacl共享同一个插件,因此直接复用后者的enabledPlugin(即同一个 Proxy),避免产生两个不同的代理泄露不一致性。

测试 mimeTypes.test.js 甚至专门验证了五层嵌套访问enabledPlugin[0].enabledPlugin[0]...suffixes仍能得到'pdf',证明该设计在深度遍历下也不会暴露循环引用问题。

3.5 数据来源:贴近真实 Chrome 的静态清单

data.json 提供了模拟数据:4 种 MIME 类型(application/pdf、application/x-google-chrome-pdf、application/x-nacl、application/x-pnacl)和 3 个插件(Chrome PDF Plugin、Chrome PDF Viewer、Native Client)。每个插件包含name、filename、description与__mimeTypes(带双下划线前缀的内部字段,在生成对象时被跳过)。测试断言navigator.plugins.length === 3、navigator.mimeTypes.length === 4即基于此数据。

四、为何能通过"不留痕迹"检测

伪造对象不仅要"长得像",还必须"查不出是假的"。测试 index.test.js 验证了关键的一点:启用插件后,Object.getOwnPropertyDescriptor(navigator, 'mimeTypes')与plugins仍为undefined,Object.getOwnPropertyNames(navigator)中也不包含plugins。这得益于两点:

  1. 属性挂在原型上:通过Object.getPrototypeOf(navigator)上的 getter 返回伪造对象,navigator自身没有新增任何可枚举自有属性。
  2. 全面的 Proxy 伪装:ownKeys陷阱复现了原生属性列表,stripProxyFromErrors清除了错误堆栈中的 Proxy 痕迹,patchToString/redirectToString让所有方法输出[native code],配合utils.preloadCache预先缓存原生Function.toString与Reflect引用,防止检测方通过替换这些基础函数来嗅探内部实现(见 _utils/index.js)。

值得注意的是,所有注入脚本都是通过 withUtils.js 的evaluateOnNewDocument以字符串形式传递(stringifyFns/materializeFns),因为page.evaluate的序列化机制不允许直接传递函数引用——这一约束也决定了整个模块必须采用"函数字符串化 + 页面内 eval 还原"的架构。

五、测试验证:一组可复现的行为快照

该模块的测试用例(index.test.js、mimeTypes.test.js、plugins.test.js)用精确断言固化了伪装对象的行为基线,以下是几组关键断言:

数组级行为(来自 mimeTypes.test.js 与 plugins.test.js):

探测手段期望结果
Array.isArray(navigator.mimeTypes)false
JSON.stringify(navigator.mimeTypes){"0":{},"1":{},"2":{},"3":{}}
Object.getOwnPropertyNames(navigator.mimeTypes)["0","1","2","3","application/pdf",...](不含length)
navigator.mimeTypes.toString()'[object MimeTypeArray]'
navigator.mimeTypes.item()(无参)TypeError: Failed to execute 'item' on 'MimeTypeArray'...
navigator.mimeTypes.item('madness')返回第一项(application/pdf),而非undefined
'push' in navigator.mimeTypesfalse

条目级行为(来自 mimeTypes.test.js 的stealth: will have convincing mimeType entry):

探测手段期望结果
navigator.mimeTypes[0].toString()'[object MimeType]'
navigator.mimeTypes[0].__proto__[Symbol.toStringTag]'MimeType'
JSON.stringify(navigator.mimeTypes[0])'{}'
Object.getOwnPropertyNames(navigator.mimeTypes[0])'[]'
enabledPlugin五层嵌套访问.suffixes'pdf'

无泄漏验证(来自 index.test.js 的stealth: will not leak modifications):启用插件后navigator自身的属性描述符与属性名列表与原生完全一致。

此外,cat-and-mouse.test.js 中"stealth: will pass Paul Irish"测试会在启用全部 stealth 插件(含 navigator.plugins)后运行 Paul Irish 的 headless 检测脚本,其中就包含navigator.plugins.length === 0这一项,只有当所有检测项全部不命中时测试才通过。

六、局限性与注意事项

  • 数据快照的时效性:伪造内容来自 data.json 的静态快照(Chrome PDF Plugin、Chrome PDF Viewer、Native Client),与具体浏览器版本的实测数据可能略有出入;检测方若对比多版本基线仍可能发现差异。
  • 跨 iframe 场景:evaluateOnNewDocument注入不会自动作用于[srcdoc]/[sandbox]属性的 iframe(相关说明见 cat-and-mouse.test.js),这类隔离窗口中的navigator.plugins仍可能为空。
  • 无法做到绝对隐身:仓库 stealth readme 也坦言,完全阻止所有 headless 检测方式是不现实的,本模块的目标是把检测成本提升到不划算的程度。

结语

navigator.plugins反检测模块是 puppeteer-extra stealth 系列中技术含量最高的实现之一:它以data.json为数据源,借助Object.create+ 属性描述符 + 双层 JS Proxy(ownKeys/getOwnPropertyDescriptor/get陷阱)+ 函数级[native code]伪装,构建出与 headful Chrome 行为高度一致的MimeTypeArray与PluginArray,并通过原型链替换与错误堆栈清理做到"查无实据"。理解这套实现,不仅能帮你用好 puppeteer-extra 的 stealth 能力,也为自主编写反检测脚本提供了可复用的思路与工具(_utils中大量工具函数均可独立复用)。

  • 网页爬虫
  • 浏览器控制
  • 测试

【免费下载链接】puppeteer-extra

💯 Teach puppeteer new tricks through plugins.

项目地址:https://gitcode.com/gh_mirrors/pu/puppeteer-extra
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 16:23:40

深入解析Binder机制:从一次拷贝到线程池的跨进程通信实践

1. 从一个"为什么调用这么慢"的疑问说起做系统层开发的朋友,大概率都经历过这样的场景:应用层一个看似简单的跨进程调用,耗时却比预期高出一个数量级,抓耳挠腮找不到原因,最后发现瓶颈出在进程间通信的链路上…

作者头像 李华
网站建设 2026/10/15 16:23:35

Django+Vue3医院医疗设备维修管理系统实战指南

医院设备科的报修群,每天从早响到晚。呼吸机的光耦烧了、心电监护仪的导联线断了、CT机房的温度报警又触发了……报修信息零散地堆在微信里,维修工程师背着工具包满楼跑,事后的记录要么写在纸质本子上,要么压根没写。等到年底盘点…

作者头像 李华
网站建设 2026/10/15 16:23:22

Tomcat 7.0.108 部署与调优:从解压到线上稳定运行

简介:这是一份面向Java Web开发者的Tomcat 7.0.108服务器压缩包,适合用于运行JSP/Servlet应用、部署Web项目,也可作为学习Servlet容器工作原理的参考环境。压缩包共640个文件,大小约10.38MB,包含完整Tomcat目录结构&am…

作者头像 李华
网站建设 2026/10/15 16:23:02

Android Binder IPC 机制详解:从设计原理到跨进程调用实战

1. 从一个“看不见的快递员”说起:Binder IPC到底在忙什么如果你写过Android应用,大概率遇到过这样的场景:调用一个系统服务的方法,比如获取设备信息、启动一个Activity,代码里只是一行简单的函数调用,但背…

作者头像 李华