拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

补环境实战:手写document.all完整指纹绕过检测

补环境实战:手写document.all完整指纹绕过检测

做前端安全、爬虫逆向的朋友,应该都体会过那种“环境补到一半,被一个不起眼的小检测打得满头包”的感觉。我刚开始接触 web 补环境那阵子,前前后后踩了无数坑,其中第一个让我印象深刻的,就是document.all。这个老家伙在真实浏览器里活了好几十年,偏偏在 Node.js 的模拟环境里经常缺位,而很多反爬脚本的“环境自检”第一步就喜欢摸它。这篇文章是“web 补环境篇”的第 0 篇,不整虚的,直接从document.all切入,把补环境的基本思路、实操代码、验证方法和踩坑心得一次讲透。

1. 补环境这门手艺,为什么从 document.all 切入

1.1 先搞清楚“补环境”到底在补什么

市面上很多加了反爬的站点,前端 JS 在浏览器里跑得贼顺,一到你手里拿到的 Node.js 脚本里就各种报错。原因无他:浏览器里有window、document、navigator、location这些全局对象,而 Node.js 里默认一个都没有。所谓“补环境”,本质上就是在 Node.js 里用代码“造假”出这些对象,让检测脚本产生“我正跑在一个真实浏览器里”的错觉。

但这里有个关键点:补环境不是简简单单挂个同名变量就完事。检测脚本比你想象的刁钻得多,它们会验证对象的类型、原型链、方法签名、遍历行为,甚至 toString 后的输出格式。一个粗略伪造的document,很容易被一行Object.prototype.toString.call(document)打回原形。所以补环境的核心是“指纹对齐”——真实浏览器长什么样,你的模拟对象就得长成什么样,而且要经得住各种花式验证。

我第一次做补环境的时候,用的是最笨的加法:见一个变量补一个,见一个函数补一个。结果补到最后,检测脚本的水平没升多少,代码倒是膨胀得不像话。后来才明白,补环境必须从“关键的检测点”入手,而不是盲目堆变量。而document.all就是一个特别典型的检测点。

1.2 document.all 为什么是绕不开的第一关

先说一个有趣的背景:document.all是 IE 时代的产物,当年微软在 IE4 里搞出来的东西,用来直接获取文档中所有元素。后来 W3C 的标准体系里并没有给它什么好脸色,但对老网站的兼容又让各大浏览器不得不保留这个历史遗留,于是 Chrome、Firefox 里都有它,只是实现得“半死不活”。

问题就在这个“半死不活”上。检测脚本非常喜欢拿这种历史遗留对象做文章,因为:

  • 真实浏览器里document.all是“存在但 typeof 诡异”的对象;
  • 未补环境的 Node.js 里,document.all压根不存在(undefined);
  • 就算你自己拍脑袋挂了一个,也很容易在 toString、constructor、迭代器等细节上露馅。

所以很多反爬脚本会把document.all作为“敲门砖”:如果这个对象不存在或者长得不对,直接判定当前环境异常。因为它太经典、太隐蔽、又太好用。这也就是为什么我把它放在整个“web 补环境篇”的第一篇来讲:它不复杂,却能串起补环境场景下几乎所有核心知识——对象伪装、特性对齐、遍历行为、类型验证。

2. document.all 的真身:浏览器里的特殊存在

2.1 一段历史:IE 时代的遗产

要补document.all,先得知道真机上它到底是个什么东西。IE 时代,你可以通过document.all拿到页面上所有元素的一个集合,它既像数组又像对象,还能像函数一样调用:

document.all[0]; // 页面里第一个元素 document.all('id'); // IE 里可以通过 id 直接获取元素 document.all.tags('div'); // 获取所有 div 元素集合

后来标准浏览器为了兼容老页面,也实现了document.all,但收敛了很多,不再允许像函数一样调用。也就是说,在 Chrome 的现代版本里,如果你写document.all('test'),直接会报TypeError: document.all is not a function。

这一点对补环境至关重要:如果你的目标环境是模拟 Chrome,那么就绝不应该把document.all实现成“可调用函数”,否则反而会暴露。很多初学者随手一写,补出来的document.all是函数,真机对照时直接被检测脚本揪出来。

2.2 真机指纹长什么样

我在 Chrome 控制台里实际跑了一圈,把document.all的真机指纹整理成了一张表:

检测方式Chrome 中的结果说明
typeof document.all'undefined'这是 HTMLAllCollection 的特殊设计
Boolean(document.all)true虽然 typeof 是 undefined,但对象本身存在
document.all == nulltrue经典特殊规则:与 null/undefined 相等
document.all === undefinedfalse严格相等不成立
document.all.length页面元素个数一个非负整数
Object.prototype.toString.call(document.all)'[object HTMLAllCollection]'内部标签是 HTMLAllCollection
document.all.constructor.name'HTMLAllCollection'构造函数名
document.all instanceof HTMLAllCollectiontrue原型链正常
document.all('anything')TypeErrorChrome 里不可调用
for...of document.all可迭代拿到了元素序列
document.all.item(0)第一个元素方法存在且返回正确
document.all.namedItem('id')对应元素或 null按 id 查元素
document.all.tags('div')HTMLCollection按标签查元素集合

这表里最诡异的就是第一行和第三行。一个真实存在于页面里的对象,typeof居然是'undefined',而且== null也是true。这不是 Chakra 或 V8 的 bug,而是规范特意保留的“HTML All Collection”特殊行为:它内部有一个[[IsHTMLDDA]]标记,让它在布尔判断时表现为 false,在与 null/undefined 进行宽松比较时表现为相等。

这个特殊行为是补环境里最麻烦的部分,后面我会专门讲怎么处理,以及哪些地方真的没办法做到 100% 一致。

3. 从零开始手写一个 document.all

3.1 基础骨架:用 Proxy 兜住所有访问

补环境里要制造这种“看起来是原生,摸起来是原生”的对象,首选方案是 JavaScript 的Proxy。原因很简单:检测脚本什么招都可能出,访问不存在的属性、调用不存在的方法、遍历、类型转换……如果只靠手动定义有限个属性和方法,总有漏网之鱼。而Proxy提供了get、set、has、ownKeys、getOwnPropertyDescriptor等透明拦截能力,能把各种奇奇怪怪的访问全部兜住。

我第一版实现大概是这样的(基于 Node.js 环境模拟 Chrome):

function createHTMLAllCollection(doc) { // 先从 document 中收集真实元素 const elements = doc ? doc.all ? Array.from(doc.all) : [] : []; const target = function () { throw new TypeError('document.all is not a function'); }; const allCollection = new Proxy(target, { get(target, prop, receiver) { if (prop === 'length') { return elements.length; } if (prop === 'item') { return function (index) { return elements[index] || null; }; } if (prop === 'namedItem') { return function (name) { for (let i = 0; i < elements.length; i++) { if (elements[i].id === name || elements[i].name === name) { return elements[i]; } } return null; }; } if (prop === 'tags') { return function (tagName) { return elements.filter(el => el.tagName.toLowerCase() === tagName.toLowerCase()); }; } if (typeof prop === 'string' && /^\d+$/.test(prop)) { return elements[Number(prop)]; } if (prop === 'constructor') { // 构造函数名要像原生的 HTMLAllCollection return function HTMLAllCollection() { /* native code */ }; } if (prop === Symbol.toStringTag) { return 'HTMLAllCollection'; } if (prop === Symbol.iterator) { return elements[Symbol.iterator] ? elements[Symbol.iterator].bind(elements) : undefined; } if (prop === 'toString') { return function () { return '[object HTMLAllCollection]'; }; } if (prop === 'valueOf') { return function () { return this; }; } if (prop === 'toJSON') { return undefined; } return elements[prop]; }, set(target, prop, value) { return true; }, has(target, prop) { return true; }, ownKeys(target) { const keys = []; for (let i = 0; i < elements.length; i++) { keys.push(String(i)); } keys.push('length', 'item', 'namedItem', 'tags', 'constructor', 'toString'); return keys; }, getOwnPropertyDescriptor(target, prop) { return { enumerable: true, configurable: true, writable: false, value: this.get ? this.get(target, prop) : undefined }; } }); return allCollection; }

这里有一个细节:Proxy 的 handler 里getOwnPropertyDescriptor中不能用this.get,应该直接用一个内部辅助函数,或者把 get 逻辑抽出去。我在实际项目里是把核心取值逻辑封装成一个getValue函数,然后 get 和 getOwnPropertyDescriptor 都调它,避免处理不该暴露的属性。这只是工程细节,但踩过坑的朋友应该懂:一旦检测脚本用Object.getOwnPropertyDescriptor(document.all, 'length')来验证,如果 descriptor 长得不对,全盘皆输。

3.2 补齐细节:toString、constructor、迭代器

补环境里最容易被忽视的,是那些“看起来不重要”的细节。我一个个讲。

Object.prototype.toString 检测

检测脚本最常用的就是:

Object.prototype.toString.call(document.all) === '[object HTMLAllCollection]'

这里有两个实现层面:一个是document.all自己有没有Symbol.toStringTag,另一个是Object.prototype.toString走完常规流程后拿到什么结果。在 proxy 上,直接设置Symbol.toStringTag为'HTMLAllCollection'就能搞定。

但注意,document.all.toString()的结果也要一致。在真机上,document.all.toString()返回的是'[object HTMLAllCollection]'吗?我实际验证过,在 Chrome 里,直接调用document.all.toString()返回的是原生代码字符串化后的'[object HTMLAllCollection]'。所以模拟的toString也这么返回是对的。

constructor 的伪装

检测脚本可能写:

document.all.constructor.name === 'HTMLAllCollection' document.all instanceof HTMLAllCollection

由于 Node.js 全局里没有HTMLAllCollection这个构造函数,你必须在作用域里造一个:

function HTMLAllCollection() {} HTMLAllCollection.prototype = {}; // 然后让 proxy 的 constructor 指向它 document.all instanceof HTMLAllCollection // true

这里有个陷阱:如果检测脚本访问document.all.constructor.toString(),原生的构造函数打印出来是function HTMLAllCollection() { [native code] }。所以你的函数体内最好也放[native code]:

function HTMLAllCollection() { [native code] }

虽然 JS 引擎不会真的把它当 native code,但字符串化后至少长得像。

迭代器与类数组特征

真机的document.all是可迭代的,for...of能遍历元素,同时它也有length和数字索引。用 Proxy 实现时,要在get里处理数字索引属性,并让Symbol.iterator指向元素数组的默认迭代器:

if (prop === Symbol.iterator) { return elements[Symbol.iterator] ? elements[Symbol.iterator].bind(elements) : undefined; }

千万别图省事直接return elements[Symbol.iterator],因为迭代时this会被绑定到 document.all 这个 Proxy 上,导致迭代内部状态出问题。bind 到真实的数组上才最稳。

3.3 真机对照验证脚本

补完之后,强烈建议把你的模拟对象和真实的 Chrome 环境做一次自动化对照验证。下面这段验证脚本我一直在用,跑一遍就知道差距在哪:

function envCompare(name, realFn, fakeFn) { let realValue; let fakeValue; let realError = null; let fakeError = null; try { realValue = realFn(); } catch (e) { realError = String(e); } try { fakeValue = fakeFn(); } catch (e) { fakeError = String(e); } // 输出差异 const real = realError ? 'ERROR: ' + realError : String(realValue); const fake = fakeError ? 'ERROR: ' + fakeError : String(fakeValue); const ok = real === fake; console.log(`${ok ? 'PASS' : 'FAIL'} - ${name}`); if (!ok) { console.log(` 浏览器: ${real}`); console.log(` 模拟器: ${fake}`); } }

对照项至少要有这些:

  • typeof document.all
  • Boolean(document.all)
  • document.all == null
  • document.all === undefined
  • document.all.length
  • Object.prototype.toString.call(document.all)
  • document.all.constructor.name
  • document.all instanceof HTMLAllCollection
  • 遍历前三个元素标签名
  • item(0) === document.all[0]
  • namedItem('id')的结果

第一次跑出来,typeof document.all和document.all == null这两项几乎百分百是 FAIL,原因就是前面提到的[[IsHTMLDDA]]特殊行为。我说一下这个问题在实操中的取舍。

4. 实战中常见的检测姿势与应对

4.1 检测代码的典型长什么样

我整理一下实战中比较高频的document.all检测姿势,基本覆盖了 90% 的场景:

检测方式说明补环境应对
typeof document.all === 'undefined'利用 IE 特殊逻辑无法完美对齐,但真实 Chrome 也返回 undefined,所以 Node 下返回 undefined 反而一致
if (document.all)判断对象存在性,利用 ToBooleanProxy 对象默认 truthy,能过
document.all == null/document.all == undefined宽松比较语言层面无法模拟,需取舍
Object.prototype.toString.call(document.all)检查内部标签用Symbol.toStringTag搞定
document.all.constructor.name检查构造函数名伪造构造函数搞定
for (const el of document.all)判断是否可迭代绑定数组迭代器搞定
Array.from(document.all)迭代器 + length 双重检测二者都补上就稳
document.all.length > 0页面元素数量判断结合文档内容返回数量
document.all.item(0) === document.all[0]方法结果一致性实现 item 方法时保证与索引一致
Object.getOwnPropertyNames(document.all)属性列表ownKeys 返回合理集合

4.2 那些 JS 语言层面模拟不了的“死穴”

这里必须说点掏心窝的话:typeof document.all === 'undefined'和document.all == null这两条,如果你真的要在 Node.js 里做一个“存在且类型是 undefined 且宽松等于 null”的对象,几乎是不可能的。因为:

  • typeof是 JS 语言层面的运算符,它只认当前变量或属性是否存在,一旦存在,只能返回六大基本类型之一,不可能返回 undefined;
  • 宽松相等== null走的是规范里的 IsLooselyEqual 流程,普通对象除非内部槽[[IsHTMLDDA]]被设置,否则不可能与 null 相等;
  • 这个内部槽位只存在于真正的 HTMLAllCollection 等极少数内置对象中,V8 没有对普通 JS 对象开放设置入口。

那怎么办?我在实际项目里总结出三个取舍方向:

  1. 放弃这两条。因为大多数检测脚本不会单独依赖这两条,它们往往只是“顺手测一下”。如果整个检测逻辑里只有这里不一致,通常不会触发风控。
  2. 让document.all不存在。反正 Chrome 里typeof document.all === 'undefined'也是真的,你在模拟环境里不给 document 挂这个属性,反而匹配了这条判断。当然,这样Boolean(document.all)也是 false,会暴露另一类检测。
  3. 结合场景选择。当你检测到的风控脚本明确在使用document.all做环境指纹,并且既查存在性又查布尔值,那就优先保证Boolean(document.all)为 true 和 toString 正确,日常风险可控。

说白了,补环境不可能追求 100% 的像素级一致,能做的是让大多数检测逻辑得出与浏览器一致的结论。极端冷门的检测方式,等遇到了再针对性处理。

5. 踩坑实录与方法论沉淀

5.1 我踩过的几个坑

第一个坑:给document.all加了函数调用后,被检测脚本精准识别。因为有些老代码迁移过来的脚本,真机上调用document.all('abc')会抛 TypeError,而我模拟的版本能正常返回,立刻露馅。后来我查了真机行为,把 target 改成了“一调用就抛 TypeError”,这个问题才解决。

第二个坑:忘了处理Object.getOwnPropertyDescriptor。检测脚本不直接访问属性,而是用这个 API 检查属性描述符,结果返回的 descriptor 里 value 是 undefined,length 直接显形。后来我把取值逻辑抽出来,在 descriptor 里也正确返回 value。

第三个坑:迭代器没有 bind 到真实数组。那段时间Array.from(document.all)在模拟环境里返回空数组,但在浏览器里有内容。折腾了半天才发现是this绑定的问题,加一行bind(elements)就好了。

第四个坑:namedItem和tags的真实返回类型。真机里document.all.namedItem('id')返回的是 HTMLCollection 或者一个元素,而不是简单的数组。我在模拟环境里直接返回了数组,导致检测脚本的XXX instanceof HTMLCollection判断失败。解决办法是让这些方法也返回带有相应原型链的集合对象,或者退而求其次返回兼容对象。

5.2 补环境的一点私人体会

补环境不是背代码,而是理解“真机指纹”。你每补一个对象,脑海中都要有一张“真机对检测方式的响应表”,然后一项一项对齐。这个过程中最忌讳的就是“差不多就行了”——检测脚本往往就藏在那些你以为没人在意的角落里。

还有一点:补环境补的是“行为一致性”,不是“属性数量一致性”。与其堆砌一百个用不到的属性,不如把一个常用属性的语义、类型、遍历行为都打磨到位。我第一次手写document.all的时候,就觉得它和真正工程的差距不在代码量,而在对细节的把控上。

后续的“web 补环境篇”里,我会继续沿着这个思路,逐个拆解window、navigator、screen、canvas指纹等常见高频检测对象。第一篇用document.all开刀,是因为它足够简单、足够典型,也足够让你体会“补环境到底补的是什么”。如果你也在补环境的路上踩过坑,欢迎对照着这篇文章自查一遍,说不定能省下几个小时的调试时间。

返回列表