☰
我总结的 js 逆向方法论(上)
2026/11/3 2:43:16 网站建设 项目流程

目录

  • 1. 对抗 TLS/JA3 指纹识别、请求头识别与HTTP协议版本
  • 2. 在多轮请求中正确传参
  • 3. 越过 debugger 中断
  • 4. 迈出第一步:加密参数定位
  • 5. 开始逆向
    • jsdom 食用方法

我做猿人学网站的题目已经过半,所以我觉得有必要停下来,总结一下逆向过程中的一些方法。当然,这些方法是我站在猿人学网站的角度得出的,在实战中必然要根据实际情况而修改,但是参考性还是有的。

至于下篇,那就要等我继续向后推进(踩坑)了。

1. 对抗 TLS/JA3 指纹识别、请求头识别与HTTP协议版本

在客户端与服务端建立TLS连接时,有一个步骤是发送ClientHello,这其中会包含客户端支持的 TLS 版本、支持的加密套件(及其排列顺序)、支持的扩展(及其排列顺序)以及椭圆曲线相关的一些元数据,它们组成了JA3 指纹。

由于这些元数据在不同的客户端的底层实现上有差异(比如 powershell 的Invoke-RestMethod、NodeJS 的fetch函数),且在同类型的客户端上相似(比如各种浏览器),所以服务端可以通过这些元数据来初步筛选哪些请求是来自于爬虫,哪些请求是来自于真正的浏览器(参考猿人学第19题:乌拉乌拉乌拉,这道题使用 NodeJS 用fetch发起请求会返回{ "error": "token failed" },使用 Python 的 requests 库或者用 powershell 的Invoke-RestMethod就可以正常返回)。

所以,如果爬虫的逻辑没有问题,但是脱离浏览器就怎么也拿不到数据时,在考虑上 Playwright 或者 Selenium 之前,不妨换一种编程语言发起请求(或者直接伪造 JA3 指纹,一劳永逸)。

另外一个需要注意的就是请求头顺序。在浏览器中见到的请求头是按照首字母排序的,A 在前 Z 在后,有的场景会利用这一点检测爬虫(参考猿人学第3题:访问逻辑 - 推心置腹刚入门那会儿,半天不知道自己错哪了)。解决方法也很简单,用 BurpSuite 或者 Fiddler 之类的抓个包,里面的请求头顺序是准确的。

再就是HTTP协议版本,比如猿人学第18题:天杀的Http2.0(地址是match/17)。这道题使用 Python 的 requests 库是拿不到结果的,因为 requests 库底层只支持 HTTP 1.1,NodeJS 支持 2.0 版本。

2. 在多轮请求中正确传参

有一些网站在获取重要数据的时候可能会发起多次请求,而不是只依赖一次请求中参数值 / Cookie 值的计算。

有的会发起几轮请求,在后面的轮数中携带前几轮的部分响应数据作为某个请求参数的值(猿人学有很多题都是要发起两次请求,第二次请求要携带第一次请求返回的时间戳作为第二次中加密的明文的一部分,在此不一一列举),有的会要求两次请求的 Cookie 必须一致(猿人学第3题:访问逻辑 - 推心置腹),有的会根据第一次请求携带的 Cookie 值下发与之绑定的混淆数据,在第二次请求必须携带通过前一次的混淆数据计算出来的值和与之对应的 sessionid (参考猿人学第13题:入门级cookie),还有的就是单纯让在请求数据之前再 get 个 time 之类的(这是个新手题,具体是哪个我忘记了)。

在真实的场景中,即使反混淆做的再好,也可能会在这些地方疏忽,浪费不必要的时间,更何况可能有的网站在请求核心数据之前的每一轮作为校验的请求都有混淆。同时也多留意一下 Set-Cookie 响应头,通过观察它的变化可能会让自己少走些弯路(比如猿人学13),而且下一个请求所需的部分关键数据也许就在上一次响应的 Set-Cookie 中(甚至有的可能是 Http-Only,控制台调试根本看不到)。

所以我们的重心虽然偏向混淆代码中的计算,但是也不能忽略这些。

3. 越过 debugger 中断

拿到一个场景,开开心心F12然后立马断在了一个不知道是哪里的debugger上。这是反调试的第一层阵地。如果是断在一个固定的setTimeout((function(){debugger;}),1000),直接右键把这行代码忽略掉就可以了:

这么容易绕过,所以混淆代码一般采用Function构造一个debugger出来:

// 省略setInterval等Funtion('debugger')()newFunction('debugger')()Function.constructor('debugger')()Function.constructor('debugger').call(null,114514)Function.constructor('debugger').apply()_0x345['cons'+_0x567(0x3a)]('de'+_0x444['ef3A==']).apply(_0xdeadbeef(0x4f))

这个时候,由于通过Function构造出来的函数会被分配到一个叫VM + 编号的文件去执行,每一次setInterval或类似原理的函数触发都会创建一个新的编号,所以不能在文件里面右键 debugger 去忽略,因为浏览器是通过文件名记住调试标记 / 不再暂停的位置的。需要 hookFunction.prototype.constructor(注意一个有意思的点,Function.prototype === Function.constructor.prototype):

// debuggerHooker.jsletc=Function.prototype.constructor;// 如果Function 传参个数大于 1,只有最后一个会被当作函数体,// 前面的都是参数。例如:// Function('a', 'b', 'debugger') <=>// function(a, b){debugger;}// 另外需要注意的是,这里**一定、一定**不能写 Function.constructor = ...,// 因为 constructor 函数位于 Function 的原型链上,这样改一旦调用方// 通过 Function.prototype 调用构造器,那么就相当于白改了Function.prototype.constructor=function(...args){// 防止 Function()if(args.length>0){// 防止 Function(数字) 报错constfuncBody=String(args[args.length-1]);if(/\bdebugger\b/.test(funcBody))returnfunction(){};}returnc(...args);};// 之前的 hook 中断了 Function.constructor 的原型链,需要连起来:Function.prototype.constructor.prototype=c.prototype;// 检测比较浅层的网站到这里就已经可以正常绕过 debugger 了,下面是深层一点的检测// 对于每个 Function 实例,它们的 name 属性其实是描述符,默认 configurable: true、// writable: false。前面修改了 Function.prototype.constructor,为了防止// 潜在的检测,要把名字改回去;// configurable: true 意味着可以使用 Object.defineProperty 函数重定义,// writable: false 意味着不能通过 Function.prototype.constructor.name = ''// 的方式修改 name,会静默失败Object.defineProperty(Function.prototype.constructor,'name',{value:'Function',// Function.prototype.constructor === Functionwritable:false,enumerable:false,configurable:true});// .length 默认是1Object.defineProperty(Function.prototype.constructor,'length',{value:1,writable:false,enumerable:false,configurable:true});letoriginalToString=Function.prototype.toString;functionfakeToString(){// 防止调用方看 constructor 的 toString,// 以及防止调用方使用 Function.prototype.toString.call/apply 调用 toStringif(this===Function.prototype.constructor)return'function Function() { [native code] }';// 防止 .constructor.toString.toString()if(this===fakeToString)return'function toString() { [native code] }';returnoriginalToString.call(this);}// 别忘了 fakeToString 自己的名字也得改Object.defineProperty(fakeToString,'name',{value:'toString',writable:false,enumerable:false,configurable:true});// 本来 fakeToString 的 length 就是 0,这里// Object.defineProperty(fakeToString, 'length', {...});// 就不用写了// 原生 toString 没有 prototype 属性deletefakeToString.prototype;// 记得覆盖 toStringFunction.prototype.toString=fakeToString;

当然了,上述代码虽然已经很完善了,但仍然有其他手段进行更深一步的检查。攻与防之间的猫鼠游戏永不停歇。之后要是真的遇到连调试都进不去的,我会再进行补充。

4. 迈出第一步:加密参数定位

最简单的,就是参数名是一个比较长的、具有特异性的词,而且代码中的变量名跟参数名一致的时候(不过只要混淆稍微复杂一点这个就难以遇到)。这个时候直接在代码全文里面Ctrl+F搜索就好了,无论是 Cookie 还是请求参数。这也是网上的逆向课程里面经常说的一点。如果参数名比较短、没有太大特异性,出现了很多匹配位置时,可以根据页面的加密风格,搜索可能的对该参数名对应的变量的定义或者可能的对对应变量的修改。比如:

vartoken=vartoken,token,token+=token['push'](

等等,这样能加快定位的速度,即使参数名和变量名不一致,在搜索的过程中往往也能定位到参数名和变量发生联系的位置。

对于请求参数来说,如果上述方法失效,可以在开发者工具面板里面选中网络 / Network,然后查看那个请求的启动器 / 发起程序,在靠近请求发出的位置下断点,观察参数值是否生成,视情况向后单步调试或者将断点向上一个前移,直到定位到赋值发生的位置(我自己经常忽略前一步,直接去翻堆栈)。

对于 Cookie 参数来说(忽略之前讨论的 Set-Cookie 头设置的),情况就有些复杂了。与校验相关的 Cookie 往往都是通过document.cookie进行存储(至少对于我目前见过的来说是这样的,不过也不排除别的,比如浏览器 APIcookieStore等)。Cookie 的格式是参数名=参数;,然后后面跟了失效时间,存储路径,是否是 HttpOnly 等等的数据。需要注意的是浏览器真正向服务器发的仅有参数名=值; 参数名1=值1;...这个序列,这之后的那些元数据都是给浏览器看的。

由于 Cookie 本身的格式特点,所以混淆后的代码大概率不会像document.cookie = `cookie_name=${cookie_name}`;因为这样通过 Cookie 参数名就可以定位。将cookie_name拆分成数个字符串、Cookie 值对应的参数名称与代码中的变量名称不一致才是常态。失去了堆栈的记录,幸运的是我们还可以 hookDocument.prototype.cookie来追踪赋值发生的位置,刚好弥补了 Cookie 无法像请求参数一样可以通过请求的发起程序堆栈进行追踪这一点:

// cookieHooker.js// 从根源 hook,防止检测 Object.getOwnPropertyDescriptor(document, 'cookie')let_cookieDes=Object.getOwnPropertyDescriptor(Document.prototype,'cookie');let_targetDesp={set(val){if(String(val).indexOf(参数名)!==-1){console.log('参数名被捕获!');debugger;// 或者不中断程序,用 console.trace 函数}_cookieDes.set.call(this,val);},get(){return_cookieDes.get.call(this);},configurable:true,enumerable:true};// 下面的两处 value 可能会随浏览器种类变化// 如果真的出了问题再改Object.defineProperty(_targetDesp.set,'name',{value:'set cookie',writable:false,enumerable:false,configurable:true});Object.defineProperty(_targetDesp.get,'name',{value:'get cookie',writable:false,enumerable:false,configurable:true});Object.defineProperty(Document.prototype,'cookie',_targetDesp);// 进一步伪装(一般的到这里就可以,本脚本同样考虑了比较全面的情况)letoriginalToString=Function.prototype.toString;functionfakeToString(){// 同样地,下面的 toString() 值不同浏览器可能也不一样if(this===_targetDesp.set)return'function set cookie() { [native code] }';if(this===_targetDesp.get)return'function get cookie() { [native code] }'if(this===fakeToString)return'function toString() { [native code] }';returnoriginalToString.call(this);}Object.defineProperty(fakeToString,'name',{value:'toString',writable:false,enumerable:false,configurable:true});deletefakeToString.prototype;Function.prototype.toString=fakeToString;

同样的,如果连上面的代码都捕获不到 cookie那就别逆向了给你了呗,上 AST 分析 + Playwright / Selenium。

插一句,如果某个变量挂载在window或document之类的地方上,想要追踪它也可以 hook 对应的变量,把它定义成描述符。(比如猿人学第5题与 RM 开头的那个 cookie 相关的window['_$ss']变量,这个_$ss是由两个字符串拼起来的,所以全局搜索根本搜索不到)

5. 开始逆向

先列一下我现在见过的混淆手法吧。比较常见的有把代码进行加密然后用一个解密函数解密整片代码再eval执行1、将字符串等数据打乱存进一个大数组,在代码运行时先循环移位直至正确2、将一个值在多个变量中来回传递、使用 if-else 结构的深缩进控制流平坦化3、使用大对象囊括要用到的变量或函数等,使用变量/对象键/函数时用辅助函数实时计算4以及 webpack / browserify 等打包工具的结果本身。

环境检测与混淆共生。我见过的有尝试删除 window / navigator 等变量(浏览器静默失败)、利用 nodejs 环境的隐式声明与浏览器环境的隐式声明行为不完全一样(浏览器隐式声明会默认将变量 / 函数挂到 window,之后可用 window.函数 / window.变量 食用)、检测与浏览器独有 API 相关的变量 / 函数 / 实例,并根据检测结果下发不同的盐 / key,导致能算出结果但是和浏览器不一样(最阴的一种)、利用网站的其其他工具脚本中的某个函数,检查它是否存在(点名猿人学23)、代码格式化检测,通过用正则匹配函数体等方式来确定代码是否被展开,再根据这个分发不同的 key(解决方法是本地运行保留原格式,不要复制浏览器格式化好的代码,除非可以将代码格式化检测这部分剥离)。

反调试。我见过删掉console、把console.log替换成history.pushState等卡死浏览器的炸弹的触发器、不断clear控制台导致啥结果也看不到等还有疯狂输出“人生无难事,只要肯放弃”等垃圾话的。不过这部分也相对比较容易解决,那就是只导出必要的函数而不要在混淆代码内自己用console查看结果之类的。

有的简单的混淆会把加密函数放在靠前的位置,且加密只依赖零星几个函数。这时直接检查调用点,按照调用顺序将加密函数与参数生成、明文拼接等逻辑复制到本地后,刷新界面后对拍即可,大部分简单的混淆都可以这么干,包括一些简单的 webpack。

而对于回溯深度较深的、或者检查单例模式是否生效的 webpack(参考猿人学第6题),手工一点点改环境很容易遗漏,这个时候可以请出 jsdom。

有的还会将部分页面渲染与其他的庞大的库打包成一个模块,或者使用 webpack / browserify 进行打包后再深度混淆一次。前者代码量庞大,手动扣的效率及其低下。有两种做法:第一是将混淆脚本不带别的东西放在一个自己的自定义界面跑一跑,看报错触发的堆栈底端,这里往往就是使用外部库 / 页面渲染等逻辑所在的位置。前者补全依赖,后者的话进入报错的行就能找到混淆过的 webpack / browserify 模块加载器以及整个模块其起始的执行位置,报错是由于处理不存在的前端元素。这时可以在整个文件加载的第一个模块前加一行代码,提前导出所需的加密函数所在的模块,模块编号等在翻请求堆栈的过程中会找到。第二种做法是找到使用这个加密模块的工具函数,翻请求堆栈打断点的时候往往能看到它,直接把它导出即可(详见猿人学第22题:初识 - 魔改标准算法 题解)。打包和深度混淆共有的特点就是代码量巨长,里面可能埋了数不尽的环境检测点,这个时候 jsdom 的eval函数的作用就体现出来了。

jsdom 食用方法

const{JSDOM}=require('jsdom');constfs=require('node:fs');// 简单写写 html 文档,在简单的场景里面有个 dom 就可以constdom=newJSDOM('<!DOCTYPE html><html><body></body></html>',{url:'https://match.yuanrenxue.cn',// 影响 window.location等位置,可能被用于环境检测referer:'',// 设置 document.referer,可能环境监测runScripts:'outside-only',// contentType: '', // 设置文档类型// includeNodeLocations: false, // 是否在DOM节点上保留源码位置信息(行号、列号),用于调试,但是会显著增加内存占用pretendToBeVisual:true,// 启用视觉模拟// resources: 'usable', // 控制外部资源加载beforeParse(window){// 对window提前进行注入等操作}});constscript=fs.readFileSync('./script.js','utf-8');dom.window.eval(script);dom.window.导出函数...

目前我用 jsdom 就是这些代码了,环境检测多且杂、代码回溯层数多,难以手动扣代码,就可以用这个大幅降低逆向成功的门槛,因为甚至不需要彻底的理解代码结构。

但是,即使是 jsdom 也不是万能的,有相当一部分浏览器 API 和 jsdom 自己实现的仍有差别,必要时需要通过beforeParse函数先给 jsdom 的window“加点料”。

以下是 AI 整理的一份 API 差异表格:

分区检测项jsdomChromium判定
全局对象window.closedundefinedfalse缺失
全局对象isSecureContextundefinedtrue缺失
全局对象typeof structuredCloneundefinedfunction缺失
全局对象typeof reportErrorundefinedfunction缺失
全局对象window.chromeundefinedobject缺失
全局对象window.visualViewportundefinedobject缺失
location / history / documentlocation.ancestorOriginsundefined{}缺失
location / history / documentdocument.hasFocus()falsetrue值不同
location / history / documentdocument.designModeundefinedoff缺失
location / history / documentdocument.execCommandundefinedfunction缺失
location / history / documentdocument.scrollingElementundefinedHTML缺失
location / history / documentdocument.fullscreenEnabledundefinedtrue缺失
navigatornavigator.userAgentMozilla/5.0 (win32) AppleWebKit/537.36 (KHTML, like Gecko) jsdom/30.1.3Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36值不同
navigatornavigator.appVersion4.05.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36值不同
navigatornavigator.platformWin32值不同
navigatornavigator.doNotTrackundefinednull缺失
navigatornavigator.maxTouchPointsundefined10缺失
navigatornavigator.deviceMemoryundefined16缺失
navigatornavigator.pdfViewerEnabledundefinedtrue缺失
navigatornavigator.webdriverundefinedfalse缺失
navigatornavigator.plugins.length05值不同
navigatornavigator.mimeTypes.length02值不同
navigatornavigator.userAgentDataundefinedobject缺失
navigatornavigator.userAgentData.brandsundefined[{“brand”:“Not:A-Brand”,“version”:“8”},{“brand”:“Chromium”,“version”:“156”},{“brand”:“Google Chrome”,“version”:“156”}]缺失
navigatornavigator.credentialsundefinedobject缺失
navigatornavigator.clipboardundefinedobject缺失
navigatornavigator.mediaDevicesundefinedobject缺失
navigatornavigator.serviceWorkerundefinedobject缺失
navigatornavigator.storageundefinedobject缺失
navigatornavigator.permissionsundefinedobject缺失
navigatornavigator.sendBeaconundefinedfunction缺失
navigatornavigator.geolocationundefinedobject缺失
navigatornavigator.bluetoothundefinedobject缺失
navigatornavigator.hidundefinedobject缺失
navigatornavigator.usbundefinedobject缺失
navigatornavigator.serialundefinedobject缺失
navigatornavigator.wakeLockundefinedobject缺失
navigatornavigator.xrundefinedobject缺失
navigatornavigator.userActivationundefinedobject缺失
navigatornavigator.getBatteryundefinedfunction缺失
navigatornavigator.shareundefinedfunction缺失
navigatornavigator.canPlayType video/mp4maybe值不同
screen / 窗口几何screen.width01707值不同
screen / 窗口几何screen.height01067值不同
screen / 窗口几何screen.availWidth01707值不同
screen / 窗口几何screen.availHeight01043值不同
screen / 窗口几何screen.orientation.angleundefined0缺失
screen / 窗口几何window.innerWidth10241707值不同
screen / 窗口几何window.innerHeight768899值不同
screen / 窗口几何window.outerWidth10240值不同
screen / 窗口几何window.outerHeight7680值不同
screen / 窗口几何window.devicePixelRatio11.5值不同
布局 / CSSbody.clientWidth01691值不同
布局 / CSSbody.clientHeight023值不同
布局 / CSSbody.offsetWidth01691值不同
布局 / CSSbody.offsetHeight023值不同
布局 / CSSbody.scrollWidth01691值不同
布局 / CSSbody.scrollHeight023值不同
布局 / CSSdocumentElement.clientWidth01707值不同
布局 / CSSgetComputedStyle(body).margin08px值不同
布局 / CSSCSS.supports(selector(:has(a)))falsetrue值不同
布局 / CSSmatchMedia(min-width:100px).matchesERR:ReferenceErrortrue调用即报错
布局 / CSSmatchMedia(prefers-color-scheme:dark)ERR:ReferenceErrorfalse调用即报错
布局 / CSSmatchMedia(hover:hover)ERR:ReferenceErrortrue调用即报错
布局 / CSSmatchMedia(pointer:fine)ERR:ReferenceErrortrue调用即报错
布局 / CSSelementFromPoint(1,1)ERR:TypeError{}调用即报错
事件DragEventundefinedfunction缺失
事件ClipboardEventundefinedfunction缺失
事件Touch 构造可用undefinedfunction缺失
事件IntersectionObserverundefinedfunction缺失
事件ResizeObserverundefinedfunction缺失
事件PerformanceObserverundefinedfunction缺失
事件ReportingObserverundefinedfunction缺失
事件requestIdleCallbackundefinedfunction缺失
存储indexedDBundefinedobject缺失
存储cachesundefinedobject缺失
网络fetchundefinedfunction缺失
网络EventSourceundefinedfunction缺失
网络navigator.sendBeaconundefinedfunction缺失
网络Response/Requestundefined/undefinedfunction/function值不同
图形 / 媒体 / 加密canvas.getContext(2d)null{}值不同
图形 / 媒体 / 加密canvas.toDataURL 长度ERR:TypeError2118调用即报错
图形 / 媒体 / 加密WebGLRenderingContextundefinedfunction缺失
图形 / 媒体 / 加密WebGL2RenderingContextundefinedfunction缺失
图形 / 媒体 / 加密OffscreenCanvasundefinedfunction缺失
图形 / 媒体 / 加密ImageBitmapundefinedfunction缺失
图形 / 媒体 / 加密ImageDataundefinedfunction缺失
图形 / 媒体 / 加密createImageBitmapundefinedfunction缺失
图形 / 媒体 / 加密AudioContextundefinedfunction缺失
图形 / 媒体 / 加密OfflineAudioContextundefinedfunction缺失
图形 / 媒体 / 加密speechSynthesisundefinedobject缺失
图形 / 媒体 / 加密MediaRecorderundefinedfunction缺失
图形 / 媒体 / 加密MediaSourceundefinedfunction缺失
图形 / 媒体 / 加密Notificationundefinedfunction缺失
图形 / 媒体 / 加密crypto.subtleundefinedobject缺失
Worker / 其它宿主能力Workerundefinedfunction缺失
Worker / 其它宿主能力SharedWorkerundefinedfunction缺失
Worker / 其它宿主能力ServiceWorkerContainerundefinedfunction缺失
Worker / 其它宿主能力AudioWorkletundefinedfunction缺失
Worker / 其它宿主能力schedulerundefinedobject缺失
Worker / 其它宿主能力SharedArrayBufferfunctionundefined值不同
performanceperformance.timingundefinedobject缺失
performanceperformance.getEntriesByTypeundefinedfunction缺失
performanceperformance.memoryundefinedobject缺失
performanceperformance.getEntries().lengthERR:TypeError3调用即报错
其他document.fontsundefinedobject缺失
其他navigator.schedulingundefinedobject缺失
其他webkitSpeechRecognitionundefinedfunction缺失
其他SpeechRecognitionundefinedfunction缺失
其他RTCPeerConnectionundefinedfunction缺失
其他Image 完整度undefinedfunction缺失
其他Element.prototype.animateundefinedfunction缺失
其他Element.prototype.scrollIntoViewundefinedfunction缺失

如果实在绕不开,还有两条下策:AST 语法树分析还原混淆(高难度)、保留浏览器环境,使用 Playwright / Selenium 控制浏览器(低难度)。

至此,我的方法论(上)就总结得差不多了。预知后事如何,且待我继续做题。


  1. 猿人学第5题:js混淆 -乱码增强、猿人学第29题:js加密 - 混淆 - 混乱构建,动态迷局 ↩︎

  2. 猿人学第23题:感知 - 加强ob壳混淆、猿人学第16题:js逆向 - window蜜罐 ↩︎

  3. 猿人学第22题:初识 - 魔改标准算法 ↩︎

  4. 猿人学第23题:感知 - 加强ob壳混淆 ↩︎

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

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

立即咨询