JS逆向补环境:原型链伪造的完整套路与穿帮细节
最近调一个带环境检测的加密站点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对象并为它设置了href、pathname等属性看起来没问题。可一旦页面代码调用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 形态就能区分环境真伪。这类检测尤其喜欢用在webdriver、phantom、callPhantom、__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; }在浏览器控制台里把navigator、location、document、history、screen、window等对象分别跑一遍拿到它们的原型链层级、构造器名、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.prototypeconst 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为 truefakeNavigator.constructor.name为 NavigatorObject.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.defineProperty或Object.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 正式跑之前把最小可行用例跑透最后一个可能不是技术上而是工作方法上的建议。补环境做到后面目标站点往往会输出一大段压缩混淆代码直接丢进沙箱跑报错信息也难定位。我的习惯是先构造一个最小用例拆出下面几类只有一个对象访问的用例验证某个对象自身属性和原型属性补得对不对一个函数调用链的用例验证页面某段具体逻辑在补环境里能否正常执行一个最终跳转/加密决策的用例验证整条业务链路是否闭环。最小用例跑通后再逐步加复杂逻辑。直接上来跑全量代码遇到报错满屏都是排查成本太高远不如从最小用例开始逐层扩展来得稳。用这套流程我之前很多次遇到“所有属性都补了但就是报错”的情况都能在半小时内定位到根因。补环境这件事很多时候不是比谁补得多而是比谁能更快定位到缺口。原型链的伪造则是这里面最容易埋雷、也最容易被忽略的一块。希望这篇文章能把你的调试路线理顺少踩几个我踩过的坑。