JS逆向补环境:原型链伪造的完整套路与穿帮细节
2026/9/23 5:02:21 网站建设 项目流程

最近调一个带环境检测的加密站点,window、navigator、document 这些老熟人都补了一圈,代码还是卡在一个莫名其妙的 undefined 上报错。顺着调用栈翻到底才发现,问题根本不是缺值,而是某个构造函数对应的原型链上少了一个 Symbol.toStringTag——页面在某个分支里把对象往 Object.prototype.toString 里一丢,环境真不真瞬间现形。

这种问题在 JS 逆向补环境里太典型了:实例属性补得满满当当,原型链层次却一片空白。今天借这个话题,把原型链层次的属性、方法伪造套路从头到尾拆一遍,讲清楚检测方到底怎么测原型链、我们应该在哪一层动手、有哪些细节会让整个伪造直接穿帮。内容面向已经接触过 jsdom、vm 沙箱等基础补环境方案,但在对抗高版本检测时有点手忙脚乱的朋友。读完之后你至少能回答一个问题:某个对象报错时,我该从哪里开始查原型链。

1. 白补了半天还是报错:原型链检测在环境代码里是怎么埋雷的

1.1 你以为的环境检测和你遇到的环境检测是两回事

很多朋友做补环境,是从一套固定的“环境模板”开始的。这套模板里列着几十个全局属性,从 window、navigator、document 一直挂到 screen、history、localStorage,看起来覆盖非常全面。但不少模板有一个通病:所有对象都是用普通对象的方式构造的,比如window.navigator = { userAgent: '...', platform: '...' },然后在一层一层往里塞属性。

这种做法的思路是:检测方既然会读取某个属性,那我们把属性值伪造得和真浏览器一样,不就行了?

问题在于,读取属性的过程本身就是一个可以被检测的过程。JS 里访问一个属性,比如navigator.webdriver,并不是简单地从对象上找一个 key,而是沿着原型链一路向上查找。检测方可以不下手查值,而是下手查这个属性到底存在于哪一层、它对应的属性描述符长什么样、它的 getter 是不是原生函数。你只要是用普通字面量对象拼出来的环境,这些原形链层级、描述符细节、构造器关系,全部经不起推敲。

打个比方,真实浏览器里的 Navigator 对象是有一个“家族族谱”的。它自己的原型是 Navigator.prototype,再往上原型是 EventTarget.prototype,再往上是 Object.prototype。属性看起来挂在对象上,实际很多都是在原型链上的某个祖先那里定义的。补环境的时候只做一个假对象塞进沙箱,就好比你只是给一个人贴了个名牌,却压根不管他的家族关系,人家一查身份证号和户籍,立马露馅。

1.2 属性搜索机制:检测方比你想的更擅长用原型链

理解原型链为什么重要,要从 JS 的属性查找机制说起。执行obj.name时,引擎先看 obj 自有属性里有没有 name,没有再顺着__proto__去 obj 的原型对象里找,一路找到 Object.prototype。如果还没有,返回 undefined。所以一个属性最终能不能拿到,取决于整条链上有没有。

检测方从来不会只用一层。实际对比真浏览器与补环境时最大的差异就体现在这里:

  • 真浏览器里navigator的原型链是Navigator.prototype -> EventTarget.prototype -> Object.prototype
  • 很多补环境代码里的navigator原型链是Object.prototype

这种差异导致两个非常明显的检测点。一个是navigator instanceof EventTarget之类的instanceof判断,另一个是Object.getOwnPropertyDescriptor(Navigator.prototype, 'webdriver')能否取到正确的描述符。你实例属性补得再像,这两招一上来就区分开了。

再举一个更隐蔽的例子。你伪造了一个location对象,并为它设置了hrefpathname等属性,看起来没问题。可一旦页面代码调用location.toString(),有些检测方不会直接看这段代码逻辑,而是拿Object.prototype.toString.call(location)去校验,期望结果返回[object Location]。在浏览器里,Location.prototype上有一个Symbol.toStringTag属性,值是'Location'。你伪造的普通对象没有这个标记,返回值是[object Object],身份立刻暴露。

1.3 补环境的大坑:只看到对象,没看到对象的结构

再往深一层说,JS 环境检测从来不是检测单个“值”,而是检测一套“结构”。当你把一个全局对象放进沙箱时,页面通过不同路径访问同一个对象,可能是在验证是不是同一个引用;页面通过构造函数造出一个实例,可能是在验证实例与构造器的原型关系;页面拿到原型对象,可能是在对比原型对象上的方法列表和原生函数的 toString 结果。

所以,补环境不应该是一张“对象名 + 属性值”的哈希表,而应该是一棵有结构的原型树。树要分三层来搭:构造函数、构造函数的 prototype 对象、最终实例。三者之间的隐式互指关系一旦断了,哪怕值正确也会执行失败。

这也是为什么很多人在补环境时明明把所有“看得见”的变量都补了,还是被某一段混淆代码报错拦住。那段代码很可能不是要一个值,而是在验证某个对象的身份结构。失之毫厘,谬以千里,就是这个道理。

2. 从检测方的角度拆解:几类原型链级指纹的真实用例

2.1 原型方法 toString 原生校验

这一类检测在各类防护代码里出现频率极高。核心逻辑是拿Function.prototype.toString去检查某个函数到底是不是运行时提供的内置函数。

function isNativeFn(fn) { return Function.prototype.toString.call(fn).includes('[native code]'); }

如果你的补环境里某个方法是用 JS 自己写出来的普通函数,比如:

navigator.sendBeacon = () => { return true; };

那么上面调用isNativeFn(navigator.sendBeacon)时,返回的是整个函数体字符串,而不是function sendBeacon() { [native code] },直接判定为非原生。

更麻烦的是,检测方还会检查这个结果字符串本身。它可能解析函数名、参数个数、包含的代码片段是否匹配已知原生函数特征。所以伪造一个方法时,不能只是“功能上能用”,还要在“toString 表现上像原生”。最稳妥的做法是让待补环境里的方法本身就是从干净上下文里创建出来的原生函数,而不是在沙箱里手写一遍功能逻辑。

2.2 属性描述符存在性与描述符形态校验

第二种常见的检测手法是绕过“值”,检查属性描述符。例如 Chrome 里navigator.webdriver实际上是定义在Navigator.prototype上的一个 getter,描述符大概长这样:

{ get: function () { return false; }, set: undefined, enumerable: true, configurable: true }

如果补环境时直接给 navigator 实例加一个webdriver: false,那么Object.getOwnPropertyDescriptor(navigator, 'webdriver')在真浏览器里返回的是 undefined(因为它定义在原型上),在你的环境里却能拿到一个 value 描述符。检测方只需要判断返回值里的 get、set 形态,就能区分环境真伪。

这类检测尤其喜欢用在webdriverphantomcallPhantom__selenium_evaluate这类自动化特征属性上。检查点在各个浏览器里可能都不太一样,有的把属性挂在实例上,有的挂在原型上。预先从真浏览器里抓一份描述符快照,再照着描述符定义到正确的位置,是唯一相对靠谱的补法。

2.3 constructor.name 与 Symbol.toStringTag 校验

constructor.name属于一种低成本、高收益的检测方式。比如检测方取navigator.constructor.name,期望得到'Navigator';取window.constructor.name,期望得到'Window'。你的补环境如果只是const navigator = { ... },那navigator.constructor指向的是Objectname'Object',直接被识破。

对策是构造一个真正的function Navigator() {},把Navigator.prototype上的 constructor 指回自己,再用Object.create(Navigator.prototype)创建 navigator 实例。这样navigator.constructor === Navigatorname就是'Navigator'

Symbol.toStringTag 的作用则是配合Object.prototype.toString使用的。检测方只需要把目标对象丢进{}.toString.call,就能拿到一个格式化的字符串。真浏览器里navigator返回[object Navigator]location返回[object Location]window返回[object Window]。这些结果主要来自各自原型链上的Symbol.toStringTag属性,而不是对象自己的某个字符串值。补环境时必须把这个属性放到正确的位置上,否则Object.prototype.toString一定穿帮。

下面用一个表把这几种手法和对应的补法做一个对比,方便后面实操对照。

检测手法常见检测点伪造思路
Function.prototype.toString 原生性检查方法名是否是已知原生名、结果是否包含 [native code]从干净上下文创建函数,避免沙箱里手写方法体
属性描述符检查属性挂在实例还是原型、是否 getter/setter、enumerable/configurable 值按真实描述符在对应层级用 Object.defineProperty 定义
constructor.name 检查某对象.constructor.name 是否匹配预期建立真实构造器,实例的 prototype.constructor 指回构造器
Symbol.toStringTag 检查Object.prototype.toString.call 的结果在正确原型上挂 Symbol.toStringTag 正确值
instanceof 与原型链检查对象 instanceof 某构造器是否成立用对象原型链逐级建立真实继承关系

3. 伪造实操:分层补全从实例到构造器的完整链路

3.1 先抓一份真环境原型链快照再动手

见过很多做补环境的同学,一上来就凭记忆写代码,这个属性补一个、那个方法补一个,最后越补越乱。我的建议是,正式动手前先在干净浏览器环境里抓一份数据基准,把每个关键对象的结构统统计清楚。

抓取逻辑很简单,用一段脚本遍历关键对象,逐级收集__proto__、constructor 名称、prototype 上的自有关键属性、每个属性的描述符信息。举个例子:

function snapshotProto(obj, maxDepth = 6) { const result = []; let cur = obj; for (let i = 0; cur && i < maxDepth; i++) { const info = { layer: i, ctorName: cur.constructor && cur.constructor.name, ownKeys: [...Object.getOwnPropertyNames(cur), ...Object.getOwnPropertySymbols(cur)].map(k => String(k)), toStringTag: cur[Symbol.toStringTag], }; result.push(info); cur = Object.getPrototypeOf(cur); } return result; }

在浏览器控制台里把navigatorlocationdocumenthistoryscreenwindow等对象分别跑一遍,拿到它们的原型链层级、构造器名、Symbol.toStringTag 值,以及关键属性的描述符。这份快照就是补环境时的对照表。

拿到快照后,再针对目标站点的报错点做局部补全,而不是把整个浏览器都重建一遍。很多时候你只需要把页面里实际用到的几个对象的原型链搭对,就能稳定跑过去。盲目追求“把所有对象都补上”,反而更容易在细节上出错。

3.2 构造函数、原型、实例三层各司其职

现在开始搭一个标准的三层结构。以 Navigator 举例。

第一层是构造函数。不要用字面量对象,而是声明一个真实的函数:

function Navigator() {}

第二层是原型对象。这一步最关键,要把构造函数的 prototype 指向一个继承了 EventTarget 原型的对象,并且重新设置 constructor 指向:

function EventTarget() {} Object.defineProperty(EventTarget.prototype, Symbol.toStringTag, { value: 'EventTarget', writable: false, enumerable: false, configurable: true }); Navigator.prototype = Object.create(EventTarget.prototype, { constructor: { value: Navigator, writable: true, enumerable: false, configurable: true } });

第三层是实例。创建实例时用Object.create(Navigator.prototype),而不是new Navigator(),这样就能保证实例的原型指向我们构造的 Navigator.prototype:

const fakeNavigator = Object.create(Navigator.prototype);

最后再往原型上补 Symbol.toStringTag 和关键属性描述符:

Object.defineProperty(Navigator.prototype, Symbol.toStringTag, { value: 'Navigator', writable: false, enumerable: false, configurable: true }); Object.defineProperty(Navigator.prototype, 'webdriver', { get: function () { return false; }, set: function () {}, enumerable: true, configurable: true });

这样补完以后,fakeNavigator instanceof Navigator为 true,fakeNavigator.constructor.name为 'Navigator',Object.prototype.toString.call(fakeNavigator)为 '[object Navigator]',与真浏览器表现一致。

这套三层逻辑是通用套路。无论补 location、document、history 还是其他任何 Web API 对象,核心都是这个思路:先确认真实原型链,再构造对应的构造函数和原型,最后创建实例。不要图省事直接用对象字面量。

3.3 用 Proxy 做一层兜底,但别把它当救命稻草

三层结构建好后,接下来比较常见的做法是在全局对象上挂一层 Proxy,把所有未补属性暂时伪装成 undefined 或者自动生成占位结果。这是动态补环境里非常有用的手段,开发效率高,能快速让脚本跑起来。

const fakeWindow = new Proxy(globalThis, { get(target, key, receiver) { if (Reflect.has(target, key)) { return Reflect.get(target, key, receiver); } // 开发阶段输出未补属性日志,方便定位遗漏 return undefined; }, has(target, key) { return Reflect.has(target, key) || key in classifyBrowserKeys(); } });

但我必须提醒一点:Proxy 不是万能兜底。很多高级检测方会使用Object.getOwnPropertyNames(window)Reflect.ownKeys(window)这类枚举接口,直接统计沙箱全局对象上有多少自有属性,如果发现和真浏览器差异过大,一样会判定异常。另外 Proxy 的 get 拦截次数、返回 undefined 的时序,有时也会成为埋点。所以 Proxy 只适合开发期辅助定位,正式跑的时候还是得把关键属性逐项真补,Proxy 留着处理一些低频但非致命属性。

3.4 原生函数 toString 怎么处理才不翻车

说了三层对象结构,还有一个绕不开的问题:伪造的方法如何通过 toString 检测。前面已经提过,手写一个普通 JS 函数,其结果字符串和原生函数完全不一样。常见的处理方式是尽量不使用页面运行时手写的函数来冒充原生方法,而是从干净上下文里生成:

const sandbox = vm.createContext({}); const nativeFn = vm.runInContext('(function () { return true; })', sandbox);

在独立 context 里创建出来的函数,toString 输出为function () { return true; },从格式上讲已经接近一个普通函数。但如果你要模拟的是真正有名字的内置方法,比如function sendBeacon() { [native code] },靠手写函数很难完全伪造成原生 toString 输出。

实际场景中我会给这些方法加一层包装,让 toString 返回预置的原生字符串,同时还要保证Function.prototype.toString.call得到的结果一致。这一步切忌把 toString 设置在实例属性上,要让页面调用Function.prototype.toString.call(fn)时也拿得到正确结果,就得在函数的自定义 toString 属性上做拦截。

function fakeSendBeacon() {} fakeSendBeacon.toString = function () { return 'function sendBeacon() { [native code] }'; };

这招能骗过多数字符串比对类的检测,但如果检测方调用Function.prototype.toString.call(fakeSendBeacon),结果还是会踩到我们定义的这个函数上,因为方法体返回的那个字符串本身就是我们自定义出来的。真环境里这个过程是由 JS 引擎生成的。所以更稳妥的组合是:功能逻辑外包给干净上下文生成的原生函数,外面再套一层自定义 toString 输出原生标记。具体怎么搭配要看目标站点的检测强度,没有银弹,只能逐项验证。

4. 容易被忽略的挂载细节,每一个都让项目运行失败过

4.1 描述符里的三个标志位:writable、enumerable、configurable

补环境时最容易忽略的不是属性值,而是属性描述符里的三个布尔标志。真浏览器里很多属性故意把 writable 和 enumerable 设置为 false,你补出来的对象如果默认都是 true,检测方只凭Object.getOwnPropertyDescriptor就能识别。

有三个高发场景:

  • 页面用Object.definePropertyObject.assign修改属性时,如果原属性 writable 为 false,修改会抛异常或失败;
  • JSON.stringify某对象时,只有 enumerable 为 true 的自身属性会被序列化,某属性该出现却没出现,逻辑挂;
  • 页面遍历属性时,通常用for...in只拿 enumerable 为 true 的属性,多出来或缺少都会导致计算结果不同。

处理办法也很简单:每补一个属性时都在旁边记一份真实描述符快照,照着把三个布尔位和数据属性/getter/setter 结构定好。宁可多花十分钟查描述符,也别在调试时被一个隐形异常磨一小时。

4.2 constructor.name 别挂反,原型别断开

第二类高发细节就是 constructor 的指向和 name。很多补环境代码里虽然构造了 Navigator 函数,却忘了在 Navigator.prototype 上显式设置constructor: Navigator,导致实例的 constructor 沿着原型链跑到了更上层的 Object。于是navigator.constructor.name返回 'Object',检测一抓一个准。

还有一种情况是原型链断开。比如你创建了 Navigator.prototype,却没让它继承 EventTarget.prototype,那么在navigator instanceof EventTarget这种检测里直接返回 false。检测方只需要构造一个类似EventTarget.prototype.isPrototypeOf(navigator)的判断,你伪造的结构是断一根还是断几根,在原型链上暴露得一清二楚。

所以每搭完一层对象,我都会手动跑一遍校验:

console.log(Object.getPrototypeOf(fakeNavigator) === Navigator.prototype); console.log(Object.getPrototypeOf(Navigator.prototype) === EventTarget.prototype); console.log(fakeNavigator.constructor.name === 'Navigator');

这些验证虽然机械,但能避免很多后知后觉的奇奇怪怪报错。

4.3 Symbol.toStringTag 挂在实例还是原型,直接影响检测结果

Symbol.toStringTag 这个属性有一个特点:它的位置会影响Object.getOwnPropertyDescriptor的结果,但不会影响Object.prototype.toString.call的输出。这就导致很多朋友补环境时图省事,直接往实例对象上挂一个 Symbol.toStringTag,测试 toString 输出正确,就以为万事大吉。

实际上真浏览器里 Symbol.toStringTag 通常出现在原型对象上,而不是实例自身上。检测方如果想更精细地区分,完全可以用Object.getOwnPropertyDescriptor(fakeObj, Symbol.toStringTag)来判断这个属性是实例所有还是原型所有。最稳妥的做法是按真环境快照来,快照里挂在原型就挂在原型,快照里挂在实例才挂在实例,不要自己臆测。

4.4 同一个全局对象被页面用不同路径访问,必须保证单例

伪环境里如果同一个对象存在多个副本,会引发一种很难排查的怪异问题。比如页面某处用document,另一处用document.documentElement.ownerDocument,在真浏览器里它们指向同一个对象;但如果你补环境时不小心把 document 建了两份,或者某次赋值时赋了一个新对象,两处拿到的引用不一致,页面一旦做引用比较或者往 document 上挂新属性,另一处完全感知不到,逻辑直接错乱。

规避办法是维护一张“全局对象注册表”,所有对象在初始化时只创建一次,后面所有位置都从这个注册表取引用。不要把对象作为字面量在多个地方重复创建,也别在沙箱注入过程中重新赋值覆盖。

4.5 构造器名称和函数体代码不一致也会穿帮

最后一个比较隐蔽的细节是构造器名称。很多人用function Navigator() {}来构造原型链,但在真浏览器里Navigator是一个宿主对象,它的函数体字符串是原生代码。有些检测方会单独取出Navigator.toString()或者navigator.constructor.toString()比对输出结果,如果你的函数是普通 JS 函数,函数体直接暴露身份。

这种场景没有万能解法,比较实用的思路是使用 VM 创建上下文,在干净 context 里执行function Navigator() { [native code] }这种带标记的代码,尽管这种方式也不能完全等同于宿主原生对象,但至少 toString 输出里能包含[native code]字样,能在多数检测下蒙混过去。

5. 排错效率翻倍:让环境自己告诉我们缺了什么

5.1 Proxy 访问日志定位未补属性

补环境调试最烦的不是代码难写,而是页面运行到某个深处突然报错,你无法立刻知道是哪一层的哪一个属性没补。这个问题的通用解法是给关键对象加一层访问日志 Proxy,在开发阶段把所有 get、has、getOwnPropertyDescriptor 操作都记录下来。

function makeLogProxy(obj, name) { return new Proxy(obj, { get(target, key, receiver) { const val = Reflect.get(target, key, receiver); if (val === undefined) { console.log(`[env] GET MISS ${name}.${String(key)}`); } return val; }, has(target, key) { const res = Reflect.has(target, key); if (!res) { console.log(`[env] HAS MISS ${name}.${String(key)}`); } return res; }, getOwnPropertyDescriptor(target, key) { const desc = Object.getOwnPropertyDescriptor(target, key); if (!desc) { console.log(`[env] DESC MISS ${name}.${String(key)}`); } return desc; } }); }

通过日志里大片的 MISS 输出,能快速画出页面运行时对环境的完整访问 map,逐个补齐。这个阶段有个经验:不要一看 MISS 就全补,先按页面业务逻辑需要的最小集合来,补完跑通后再逐步加可靠项,避免无关属性把页面引到更多检测分支。

5.2 用一个自检脚本 diff 真环境与伪环境的原型链差异

访问日志能发现“缺了哪个属性”,但发现不了“属性层级挂了”这类结构性问题。这种情况我一般会写一个自检脚本,分别跑在真浏览器和补环境里,输出关键对象的原型链摘要,再做对比。

脚本内容很简单,就是递归读取原型链,把每一层的构造器名、Symbol.toStringTag、自有关键属性列出来。最终对比时只需要盯几个指标:

  • 原型链层数是否一致;
  • 每一层构造器名是否一致;
  • Symbol.toStringTag 所在层级是否一致;
  • 关键原型方法存在性是否一致。

这个 diff 过程能发现的往往是那种“差一个原型继承”的隐性 bug。比如 document 的原型链差了一层 Node.prototype,某些依赖 Node 对象属性和方法的业务代码就会在运行中途报错,但你光看值又看不出毛病。只有从层级上做对比,才能快速定位。

5.3 正式跑之前,把最小可行用例跑透

最后一个可能不是技术上,而是工作方法上的建议。补环境做到后面,目标站点往往会输出一大段压缩混淆代码,直接丢进沙箱跑,报错信息也难定位。我的习惯是先构造一个最小用例,拆出下面几类:

  • 只有一个对象访问的用例:验证某个对象自身属性和原型属性补得对不对;
  • 一个函数调用链的用例:验证页面某段具体逻辑在补环境里能否正常执行;
  • 一个最终跳转/加密决策的用例:验证整条业务链路是否闭环。

最小用例跑通后,再逐步加复杂逻辑。直接上来跑全量代码,遇到报错满屏都是,排查成本太高,远不如从最小用例开始逐层扩展来得稳。

用这套流程,我之前很多次遇到“所有属性都补了但就是报错”的情况,都能在半小时内定位到根因。补环境这件事,很多时候不是比谁补得多,而是比谁能更快定位到缺口。原型链的伪造则是这里面最容易埋雷、也最容易被忽略的一块。希望这篇文章能把你的调试路线理顺,少踩几个我踩过的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询