JS逆向进阶:用原型链与属性描述符精准补环境绕过检测

📅 2026/8/11 6:51:06
JS逆向进阶:用原型链与属性描述符精准补环境绕过检测
1. 项目概述当逆向遇上原型链最近在搞小红薯的X-s参数逆向这玩意儿现在越来越“卷”了环境检测的坑是一个接一个。特别是globalThis和document.all这两个老演员用常规的window global或者直接Object.defineProperty去补经常会被检测出来导致生成的参数无效。折腾了几轮之后我发现用原型链的写法去构造这两个关键对象稳定性要高得多。这本质上是一种更贴近浏览器原生对象创建逻辑的“伪装”技术能让你的补环境代码在检测面前更像一个“良民”。简单来说这个项目就是教你如何用JavaScript原型链的底层玩法去精准、安全地补全逆向小红薯X-s参数时所需的关键环境对象尤其是globalThis和document.all这两个检测重灾区。无论你是刚入门逆向的新手还是被各种环境检测搞得焦头烂额的老鸟这套思路都能帮你理清头绪绕过不少坑。接下来我会把整个思路、核心原理、实操步骤以及我踩过的坑毫无保留地拆解给你看。2. 核心思路为什么原型链写法是“避坑神器”2.1 常规补环境的痛点在JS逆向里补环境的核心目标是让一段本应在浏览器中运行的代码能在Node.js或纯V8环境中“感觉”自己还在浏览器里。常规做法简单粗暴缺什么就定义一个什么。比如// 常见但易被检测的写法 window global; document { cookie: , all: [] }; location { href: https://www.xiaohongshu.com };这种写法对于早期的、检测宽松的站点可能有效。但现在像小红薯这类应用其反爬机制会深入检查环境对象的“血统”是否纯正。它们不仅仅检查对象是否存在或属性值对不对还会检查对象的内部属性Internal Slots比如[[Prototype]]即__proto__、[[Class]]内部标签等。属性的描述符Property Descriptor属性是数据属性还是访问器属性configurable、enumerable、writable这些标志位是否正确对象的方法与原生行为一个方法toString()的结果是什么typeof操作符返回什么对象实例的constructor指向谁当你直接用window global时window对象的原型链就断了它直接指向了Node.js的global对象而不是浏览器中Window构造函数的原型。检测代码只需要一句Object.prototype.toString.call(window)如果返回的不是[object Window]你就暴露了。2.2 原型链写法的优势原型链写法的核心思想是不直接赋值而是构造一个继承自正确原型的新对象。以globalThis为例在浏览器中globalThis、window、self在全局上下文中是同一个东西其原型链是window - Window.prototype - EventTarget.prototype - Object.prototype。我们的目标不是在Node.js里找一个现成的对象去顶替window而是凭空构造一个对象让它拥有和真实window一模一样的原型链。这样即使检测代码去追溯这个对象的“祖宗十八代”得到的也是正确的结果。同理document.all是一个历史遗留的HTMLAllCollection对象有非常特殊的类型标签和行为。直接用一个空数组[]来模拟Object.prototype.toString.call(document.all)会返回[object Array]而不是[object HTMLAllCollection]瞬间穿帮。实操心得逆向的本质是模仿而最高级的模仿是连“DNA”原型链都一起模仿。原型链写法就是从对象的“出生”开始模仿而不是给它“整容”。3. 核心细节深入理解属性描述符与原型链在动手之前必须吃透两个JavaScript基础概念这是后续所有操作的基石。3.1 属性描述符对象的“身份证”每个对象的属性除了值还有一套描述其行为的元信息这就是属性描述符。它分为两种数据描述符Data Descriptor描述一个具有值的属性。value: 属性的值。writable: 布尔值表示value是否可被修改。enumerable: 布尔值表示属性是否会在for...in循环或Object.keys()中出现。configurable: 布尔值表示能否修改描述符本身以及能否删除该属性。存取描述符Accessor Descriptor描述一个由getter/setter函数控制的属性。get: 获取属性值时调用的函数。set: 设置属性值时调用的函数。enumerable: 同上。configurable: 同上。两者不能混用。你不能在同一个描述符里既设置value又设置get。在补环境中我们经常需要精确控制属性的行为。比如浏览器中window.location是不可配置、不可枚举的吗实际上在全局上下文中window.location是configurable: false的。如果你用Object.defineProperty补的时候没设对就可能被检测到。// 一个精准补location的例子 Object.defineProperty(fakeWindow, location, { value: { href: https://www.xiaohongshu.com, protocol: https:, host: www.xiaohongshu.com, // ... 其他属性 }, writable: false, // 模拟某些环境下location不可直接赋值 enumerable: true, configurable: false // 关键与浏览器行为一致 });3.2 原型链对象的“族谱”JavaScript的继承机制基于原型链。每个对象Object都有一个私有属性[[Prototype]]可通过__proto__或Object.getPrototypeOf()访问指向它的原型对象。这个原型对象也有自己的原型层层向上直到null。构造函数Constructor用来创建对象的函数如function Window(){}。原型对象Prototype每个函数都有一个prototype属性指向一个对象。这个对象包含了由该构造函数创建的所有实例共享的属性和方法。实例Instance通过new关键字调用构造函数创建的对象。实例的__proto__指向构造函数的prototype。它们的关系是实例.__proto__ 构造函数.prototype并且构造函数.prototype.constructor 构造函数。在补环境中的应用我们要伪造一个window对象最理想的状态是创建一个空对象fakeWindow。让fakeWindow.__proto__指向Window.prototype。确保Window.prototype.constructor指向Window函数。这个Window函数的prototype属性指向Window.prototype。这样Object.prototype.toString.call(fakeWindow)就会返回[object Window]因为它顺着原型链找到了正确的“类”标识。注意事项在Node.js中并没有原生的Window构造函数。所以我们常常需要先“无中生有”地构造出整个原型链结构这听起来复杂但有了清晰的思路后就是按部就班的搭建工作。4. 实战构建用原型链打造“完美”的globalThis理论说再多不如动手。我们一步步来构建一个足以乱真的globalThis。4.1 第一步构建基础原型链骨架我们的目标是构建fakeGlobalThis - Window.prototype - EventTarget.prototype - Object.prototype这条链。首先从最顶层的Object.prototype开始它已经存在了。我们需要伪造EventTarget和Window。// 1. 伪造 EventTarget 构造函数 function EventTarget() { // 这里可以模拟一些内部状态但通常留空即可 } // 设置 EventTarget 的原型为 Object.prototype EventTarget.prototype Object.create(Object.prototype); // 修复 constructor 指针 EventTarget.prototype.constructor EventTarget; // 可以添加一些 EventTarget 的标准方法如 addEventListener 的壳子 EventTarget.prototype.addEventListener function(type, listener, options) { console.log(EventTarget.addEventListener called: ${type}); // 简单实现真实环境复杂得多 if (!this._listeners) this._listeners {}; if (!this._listeners[type]) this._listeners[type] []; this._listeners[type].push(listener); }; EventTarget.prototype.removeEventListener function(type, listener) { // ... 类似实现 }; EventTarget.prototype.dispatchEvent function(event) { // ... 类似实现 }; // 2. 伪造 Window 构造函数继承自 EventTarget function Window() { // 调用父类“构造”如果需要的话 // 初始化一些window特有的属性 } // 设置 Window 的原型为 EventTarget.prototype Window.prototype Object.create(EventTarget.prototype); // 修复 constructor 指针 Window.prototype.constructor Window; // 为 Window.prototype 添加一些标准属性或方法 Window.prototype.name ; Window.prototype.closed false; Window.prototype.self null; // 稍后回指 // ... 可以按需添加更多4.2 第二步创建实例并完善关键属性现在我们用new关键字创建fakeWindow并设置那些至关重要的、指向自身的属性。// 3. 创建 fakeWindow 实例 const fakeWindow new Window(); // 4. 设置自引用属性。这是 globalThis 的核心特征。 fakeWindow.self fakeWindow; fakeWindow.window fakeWindow; fakeWindow.globalThis fakeWindow; // 5. 将 fakeWindow 设置为 Window.prototype.self 的引用如果需要 Window.prototype.self fakeWindow; // 注意这可能会影响所有Window实例需根据场景判断4.3 第三步挂载其他BOM/DOM对象一个光杆司令window是没用的还需要把document、location、navigator等“小弟”挂上去。这里以document为例同样建议用原型链方式构建后面详述然后挂载。// 假设我们有一个伪造好的 document 对象 fakeDocument fakeWindow.document fakeDocument; // location, navigator 等同理 // fakeWindow.location ...; // fakeWindow.navigator ...; // 6. 使用 Object.defineProperty 精细控制属性描述符 // 模拟某些只读或不可配置的属性 Object.defineProperty(fakeWindow, location, { value: fakeLocationObject, // 你的伪造的location对象 writable: false, enumerable: true, configurable: false // 关键浏览器中window.location通常不可配置 }); Object.defineProperty(fakeWindow, document, { value: fakeDocument, writable: false, enumerable: true, configurable: false });4.4 第四步处理全局函数和构造函数window上还有像setTimeout、Array、Promise这样的全局函数和构造函数。我们需要将它们“搬运”过来。// 将Node.js全局对象或真实浏览器的属性拷贝到fakeWindow上 // 注意直接赋值可能会丢失函数在浏览器中的正确this绑定更安全的方式是包装。 const bomFunctions [setTimeout, setInterval, clearTimeout, clearInterval, alert, atob, btoa]; bomFunctions.forEach(funcName { if (typeof global[funcName] function) { fakeWindow[funcName] global[funcName].bind(global); // 绑定正确上下文 } else { // 如果Node.js没有可能需要自己简单实现或置为undefined fakeWindow[funcName] function() { console.warn(${funcName} not fully implemented); }; } }); // 对于构造函数如 Array, Object, Function, Promise 等直接引用即可。 // 因为它们不依赖window作为上下文。 fakeWindow.Array Array; fakeWindow.Object Object; fakeWindow.Function Function; fakeWindow.Promise Promise; // ... 其他踩坑实录直接fakeWindow.setTimeout setTimeout可能会在目标代码内部调用this.setTimeout时出错因为this指向了fakeWindow而原生的setTimeout可能依赖其他环境。用.bind(global)或写一个包装函数是更稳妥的做法。5. 难点攻克完美伪造 document.alldocument.all是一个著名的“古董”和检测点。它是一个类数组的HTMLAllCollection对象拥有非常奇特的行为typeof document.all返回undefined是的你没看错这是历史遗留的怪异行为。Object.prototype.toString.call(document.all)返回[object HTMLAllCollection]。它是一个可读的、类似数组的对象有length属性可以通过索引访问。5.1 错误做法示范// 错误1直接用数组 fakeDocument.all []; // 检测Object.prototype.toString.call(document.all) - [object Array] (失败) // 错误2用普通对象模拟 fakeDocument.all { length: 0 }; // 检测typeof document.all - object (失败)5.2 正确原型链构造法思路是创建一个继承自HTMLAllCollection.prototype的对象。但Node.js里没有这个原型所以我们需要先构造它。// 1. 伪造 HTMLAllCollection 构造函数 function HTMLAllCollection() { // 可以初始化一些内部数组来存储元素 this._elements []; } // 2. 设置其原型。HTMLAllCollection的原型应该是一个普通的对象但为了简单我们让它继承Object。 HTMLAllCollection.prototype Object.create(Object.prototype); HTMLAllCollection.prototype.constructor HTMLAllCollection; // 3. 添加必要的方法和属性使其行为像类数组 Object.defineProperty(HTMLAllCollection.prototype, length, { get() { return this._elements.length; }, enumerable: true, configurable: false }); HTMLAllCollection.prototype.item function(index) { return this._elements[index] || null; }; HTMLAllCollection.prototype.namedItem function(name) { // 简化实现 for (let elem of this._elements) { if (elem.id name || elem.name name) return elem; } return null; }; // 4. 关键重写其 Symbol.toStringTag使其返回正确的字符串 HTMLAllCollection.prototype[Symbol.toStringTag] HTMLAllCollection; // 5. 创建 fakeAll 实例 const fakeAll new HTMLAllCollection(); // 可以模拟一些元素进去比如伪造的div // fakeAll._elements.push(fakeDivElement); // 6. 将 fakeAll 赋值给 document.all并使用 defineProperty 模拟其怪异特性 Object.defineProperty(fakeDocument, all, { get() { return fakeAll; }, // 使其不可配置、不可写 configurable: false, enumerable: true }); // 7. 最诡异的一步模拟 typeof document.all undefined // 这需要修改全局的 typeof 操作符行为这是不可能的。 // 但是目标环境检测代码通常不会直接使用 typeof document.all而是用其他方式。 // 一种常见的hack是让 document.all 在布尔上下文中为 false并重写其 valueOf/toString // 实际上现代浏览器中document.all是一个“假值对象”if (document.all) 为 false。 // 我们可以通过修改 fakeAll 的 [Symbol.toPrimitive] 或 valueOf 来实现。 fakeAll[Symbol.toPrimitive] function(hint) { if (hint boolean || hint default) { return false; // 使其在布尔判断中为假 } // 对于其他类型转换返回默认行为如转字符串[object HTMLAllCollection] return null; }; // 或者更简单地直接设置 valueOf fakeAll.valueOf function() { return false; };经过以上处理你的fakeDocument.all就能通过大多数基于toString和布尔值检测的检查了。对于typeof检测需要具体看目标代码的检测逻辑有时可能需要结合代码混淆或hooktypeof操作符这属于更高级的对抗范畴。避坑指南document.all的模拟是环境检测的经典难题。如果目标代码的检测非常严格可能需要分析其具体的检测点然后针对性地修补。上述方法能解决90%基于原型链和类型判断的检测。核心就是Symbol.toStringTag和假值化处理。6. 集成与注入让补好的环境生效构造好fakeWindow和fakeDocument后下一步是如何让需要逆向的JS代码“活”在这个伪造的环境里。6.1 方法一With语句 Proxy (推荐用于复杂场景)这是最强大和灵活的方式。利用with语句改变代码块内标识符的查找规则并结合Proxy来兜底未定义的属性。// 假设 targetCode 是需要执行的、包含X-s生成逻辑的JS代码字符串 function runInFakeEnvironment(targetCode) { // 1. 准备我们的沙盒环境对象 const sandbox Object.create(fakeWindow); // 以fakeWindow为原型 // 2. 使用Proxy来捕获所有属性访问防止报错并可以动态补环境 const sandboxProxy new Proxy(sandbox, { get(target, property, receiver) { // 首先尝试从沙盒本身及其原型链上查找 if (property in target) { return Reflect.get(target, property, receiver); } // 如果找不到可以尝试动态补一个谨慎使用 console.warn([Sandbox] 访问了未定义的属性: ${String(property)}返回undefined); // 这里可以返回一个自定义的getter或者直接返回undefined // 为了更逼真可以返回一个函数当被调用时再报错或做处理 return undefined; }, has(target, property) { // 影响 in 操作符和 with 语句的行为 // 为了让 with 语句不报错我们告诉它属性存在 return true; // 激进策略所有属性都说存在。也可以根据白名单判断。 } }); // 3. 使用 with 语句将代码执行上下文绑定到我们的沙盒 const codeToRun (function() { with (sandboxProxy) { ${targetCode} } })(); ; // 4. 执行 try { const result eval(codeToRun); // 注意eval的安全风险仅在可信代码中使用 return result; } catch (error) { console.error(执行目标代码出错:, error); // 错误信息可能包含缺失的属性名方便你继续补环境 return null; } }6.2 方法二修改全局对象适用于Node.js vm模块如果你使用Node.js的vm模块创建隔离的上下文可以直接将伪造的对象注入进去。const vm require(vm); const context { // 注入我们伪造的全局对象 window: fakeWindow, document: fakeDocument, self: fakeWindow, globalThis: fakeWindow, // 注入必要的全局构造函数和函数 Array, Object, Function, setTimeout, // ... 其他 }; // 将fakeWindow的属性拷贝到context使其成为真正的全局对象 Object.assign(context, fakeWindow); const script new vm.Script(targetCode); try { const result script.runInNewContext(context); // 在这个伪造的上下文中运行 console.log(执行结果:, result); } catch (error) { console.error(VM执行出错:, error); }实操心得withProxy的方案动态性更强适合探索阶段可以快速发现缺失的环境变量。vm模块的方案隔离性更好更安全性能也可能更优适合稳定运行。我通常先用方案一调试补全环境然后用方案二来稳定生成参数。7. 调试与问题排查逆向工程师的侦探工作补环境不可能一蹴而就。执行目标代码时你会遇到各种错误。如何高效排查7.1 常见错误类型与排查思路错误类型可能原因排查思路ReferenceError: XXX is not defined环境变量缺失1. 在Proxy的get陷阱中打印缺失的属性名。2. 检查浏览器控制台在真实环境中查看window.XXX是否存在及其值。3. 在沙盒中补上这个属性可以先给一个空值或简单函数。TypeError: Cannot read property ... of undefined某个对象为undefined但代码试图访问其属性。1. 找到是哪个变量为undefined。2. 回溯这个变量的来源看是哪个环境对象没补好或返回了undefined。TypeError: XXX is not a function环境变量存在但不是函数或者函数执行上下文不对。1. 确认补的环境属性是否正确比如window.setTimeout是否真的是函数。2. 检查函数内部的this指向可能需要用.bind()绑定正确上下文。生成的X-s参数不正确或无变化环境检测通过但核心逻辑依赖的某个API行为不一致。1. 最棘手的情况。需要对比在真实浏览器和伪造环境中关键函数的输出是否一致。2. 使用toString()方法对比函数体是否被篡改。3. Hook关键函数如Date.now,Math.random对比调用次数和返回值。7.2 实用的调试技巧Console Hook在注入的代码前后重写console.log等方法将日志输出到你的Node.js控制台并附上调用栈方便定位问题来源。const originalLog console.log; sandboxProxy.console { log: function(...args) { originalLog([Sandbox Log], ...args, new Error().stack); // 添加调用栈 }, // ... 重写其他console方法 };属性访问追踪增强你的Proxy的get陷阱记录所有被访问的属性生成一份“环境需求清单”。const accessedProperties new Set(); const sandboxProxy new Proxy(sandbox, { get(target, property, receiver) { accessedProperties.add(property); // ... 原有的get逻辑 } }); // 执行完代码后 console.log(被访问的属性清单:, Array.from(accessedProperties).sort());对比执行流如果条件允许在真实浏览器中调试目标JS记录下关键函数如加密函数的输入、输出和中间变量。然后在你的伪造环境中运行同一段代码逐步对比差异找到第一个产生分歧的地方。分段执行不要一次性执行整个混淆后的巨量代码。尝试找到入口函数只执行生成X-s最关键的那部分代码减少干扰。排查心法逆向的本质是对比和模仿。你的伪造环境和真实环境的任何细微差别都可能导致失败。耐心地、一个一个地解决ReferenceError和TypeError就像拼图一样当所有错误都消失时成功往往就不远了。对于行为不一致的问题则需要化身侦探深入函数内部逻辑去比对。