1. 项目概述与分析
1.1 这个逆向任务的本质是什么
这段时间接了个活儿,需要抓取某个私募排行网站的数据做投研分析,线下没法直接拿到结构化数据,页面又看不到完整的接口返回,一抓包发现请求头和请求参数里全是加密字段。就这么入了JS逆向的坑。
先说清楚这个任务的本质:所谓JS逆向,不是“破解网站让你为所欲为”,而是把前端JavaScript里做参数加密和签名校验的那套逻辑,用我们能控制的方式还原出来,从而让服务端认为我们的请求是合法的、来自真实浏览器的,最终拿到我们需要的JSON数据。私募排行网站的业务逻辑并不复杂,核心难点基本集中在以下几层。
- 接口请求头或请求体里存在需要动态计算的参数,比如sign、token、ts、nonce之类。
- 这些参数是通过一段或几段JavaScript代码在浏览器环境里计算出来的,代码常常经过压缩、混淆,甚至套了控制流平坦化。
- 请求前可能还要完成某个环境指纹的采集,比如canvas指纹、webdriver检测、cookie生成逻辑等。
- 不完整的环境模拟会导致参数算对了,但服务端依然识别你是脚本。
本次要处理的pp网私募排行页,就是典型的“反爬参数前置校验”模式。排行页的列表接口本身不复杂,真正难缠的是启动时那段自动执行的加密逻辑:它会动态生成一个请求签名,签名依赖当前时间戳、固定的密钥片段、以及一个由浏览器环境参与计算的因子。如果你直接忽略它,接口返回的永远是“签名校验失败”或者直接403。
1.2 适合谁看,看完了能获得什么
这个项目案例适合下面几类人:刚接触JS逆向,想找个完整案例练手的;已经在做爬虫相关工作,但主要靠Selenium或Playwright硬扛,想让效率和稳定性上一个台阶的;还有对前端加密、混淆对抗感兴趣的开发者。
读完这篇博文,你能掌握的不仅仅是“把某个网站的签名参数逆向出来”这一个点,而是一整套通用方法论,包括:
- 如何通过抓包确定要逆的参数,缩小排查范围。
- 如何在Chrome DevTools里精准地下断点、看调用栈、分析作用域。
- 如何把混淆代码还原成可读逻辑,找出签名算法的本质。
- Hook与补环境的基本套路,让加密函数脱离浏览器也能运行。
- 常见反调试手段和绕过思路。
这些方法论换一个目标网站依然适用,这也是我认为比具体代码更值钱的地方。
2. 核心思路与准备工作
2.1 抓包定位:先把目标接口和加密参数圈出来
做JS逆向最忌讳的就是拿到网站就从头开始读源码,那是大海捞针。正确做法永远是先抓包,用数据流反向定位。
打开Chrome DevTools的Network面板,勾选Preserve log,然后刷新私募排行页面,一排请求刷刷刷地进来。我们要找的是那个返回排行数据的XHR请求,通常它的名字里会带有list、rank、score这类字样,响应体是JSON格式。把它的Headers仔仔细细看一遍,重点关注几个位置。
- Query String Parameters:URL问号后面的键值对,这里最容易出现签名参数。
- Request Headers:自定义Header里经常藏着token、sign、nonce。
- Request Payload:POST请求的请求体,同样可能有加密字段。
我这次遇到的情况是:排行接口的URL里带了sign、ts、nonce三个参数,其中ts是毫秒级时间戳,nonce是一串32位的十六进制字符串,sign是一长串40位的十六进制字符串。一眼看去,ts是时间戳无疑,nonce和sign都需要动态计算。先不急着看代码,多刷新几次页面,对比这几趟请求里参数的变化规律。你会发现nonce每次都变,sign也每次都变,但它们和ts之间可能存在某种关联——比如sign可能就是对某些固定参数加上ts和nonce做了一次摘要运算。
这个“多对比几次”的步骤特别重要,它能帮你快速判断哪些参数是随机生成的、哪些是时间相关的、哪些是基于其他参数派生的,后续逆向的时候可以少走很多弯路。
2.2 工具选型:Chrome DevTools为主,抓包工具为辅
工具方面,我的习惯是以Chrome DevTools为主力,Fiddler或Charles作为辅助。
Chrome DevTools里最常用的是Sources面板。通过“Search”功能可以全局搜索关键词,比如你看到接口参数里有sign,直接在Sources里搜sign:、"sign"、sign =,几乎是秒定位到加密逻辑所在文件。这个操作在绝大多数场景下都适用,因为不管代码怎么混淆,参数名最终还是要和接口字段对应上,作为纯字符串存在代码里。
Fiddler或Charles主要用来做接口的断点修改和请求重放。某些场景下,需要把请求拦下来,手动改一改参数看看服务端反应,这种情况下抓包工具比DevTools灵活得多。另外,如果目标网站有Service Worker或者比较激进的请求隔离,DevTools的网络面板不一定能完整看到请求细节,挂个代理抓包会稳定很多。
还需要提一下的是油猴脚本配合Hook。有时候直接在DevTools Console里往window上挂一些Hook函数,能极大加速逆向过程。比如想快速定位sign在哪个函数里被赋值,可以提前Hook掉JSON.stringify或String.prototype的某些方法,不过这属于进阶技巧,后面实操部分我再展开。
2.3 确定加密算法类型的基本方法
拿到一串看起来乱七八糟的密文,第一步要判断它是MD5、SHA1、SHA256、AES还是RSA。判断依据主要靠长度和字符集。
- 32位十六进制:大概率是MD5,也可能是SHA256截断。
- 40位十六进制:SHA1,或者MD5+盐再哈希。
- 64位十六进制:SHA256。
- 128位十六进制:SHA512或者MD5多次迭代。
- Base64字符串且长度有规律、结尾经常有
=:AES、DES、RSA等,需要进一步看算法模式。 - 密文长度随机、每次请求都变化:RSA可能性较大。
这次遇到的sign是40位十六进制,所以第一反应就是SHA1。之后在代码里搜sha1、SHA1、CryptoJS这些关键词,果然找到了相关逻辑。这个判断方法虽然朴素,但在实战里非常高效,能帮你省掉大量盲目分析的时间。
3. 实操过程与核心环节实现
3.1 定位sign参数的猴子补丁式搜索
打开DevTools Sources面板,用Ctrl+Shift+F全局搜索,输入sign。搜索结果会铺天盖地地返回一堆文件里出现的“sign”字样。别慌,我们先看接口URL里参数名出现的顺序和上下文。因为URL里参数是sign=xxxxx,所以往代码里搜sign大概率找到的是对象键名。
把搜索结果的几个关键文件打开,肉眼扫一眼代码。如果文件是压缩过的(一大坨代码挤在一行里),先点击左下角的{}按钮做一次格式化。格式化之后,再搜sign,出现的上下文会清晰很多。
此时可能会看到类似这样的代码:
var t = { ts: Date.now(), nonce: generateNonce(32), sign: calcSign(params, key) };看到calcSign、generateNonce这种可读性极高的命名,说明网站的前端工程师没有做深度混淆,这对我们来说就是开卷考试。但也有可能遇到a.b(c.d)这种压缩命名,这时就需要靠断点来辅助定位。
3.2 断点调试与调用栈回溯
用DevTools在搜索到的赋值语句处打一个断点,然后刷新页面。如果断点命中,说明这一行代码就是生成请求参数的地方。此时观察右侧Scope面板,所有局部变量、闭包变量、全局变量一览无余,sign的当前值、生成它时用到的params和key都能直接看到。
接下来要做的是“跳进函数内部”。比如calcSign(params, key)这个函数,鼠标点击DevTools里的Step into按钮,进入函数体,一行一行地看它是怎么把params和key变成最终的sign的。这个过程就是“调用栈回溯”。
我这次遇到的情况就比较典型,calcSign内部其实就三行核心代码:
- 先把
params对象里的键值对排序,拼接成一个字符串。 - 把拼接后的字符串和
key拼在一起。 - 然后调用
CryptoJS.SHA1做摘要,再转成十六进制字符串。
但这三行代码外面套了一层try-catch,catch块里有一段干扰代码,专门生成一个假签名来误导调试者。遇到这种故意埋坑的情况,果断跳出来看真正的逻辑流就行。
3.3 定位密钥与算法还原
密钥是怎么藏的?这个网站的做法比较典型:密钥片段分散在几个不同的常量里,通过一个字符串拼接函数组合起来。比如:
var key1 = 'abc'; var key2 = 'def'; var realKey = key1 + key2 + 'ghi';这种方式看起来简陋,但实际对抗效果不差,因为如果你只搜某个固定字符串,不一定能直接搜到完整密钥。需要先通过调用栈回溯到密钥组装的位置,才能一次性还原。
再看签名算法,代码里调用的是CryptoJS.SHA1,但CryptoJS本身支持多种算法,为什么这里选SHA1而不是MD5呢?我分析可能的原因有两个。一是SHA1输出40位,比MD5的32位长,从签名强度上看稍微好那么一丢丢;二是这个项目的后端可能是Java或老版本PHP写的,这两个技术栈对SHA1的兼容性非常高,老代码里用SHA1做签名的传统保持到了现在。
还原出来的签名算法伪代码如下:
function calcSign(params, ts, nonce) { var keys = Object.keys(params).sort(); var str = ''; keys.forEach(function(key) { str += key + '=' + params[key] + '&'; }); str += 'ts=' + ts + '&nonce=' + nonce; return sha1(str + secretKey); }核心就是“参数排序拼接 + 时间戳 + 随机数 + 固定密钥,做SHA1摘要”。这种签名设计在行业内非常普遍,它防的是参数被篡改,而不是防爬虫。但对我们来说,只要还原出这个算法,请求就能轻松打过去了。
3.4 动态时间和随机数的生成策略
再来看看ts和nonce。ts直接用Date.now()就能得到,这个没什么好说的。nonce的生成方式值得留意,它是通过一个叫generateNonce(32)的函数生成的,这个函数内部其实是用了一个伪随机数生成器,每次取随机字节再转为十六进制。
常见的实现是这样的:
function generateNonce(len) { var chars = '0123456789abcdef'; var result = ''; for (var i = 0; i < len; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); } return result; }如果我们按照这个逻辑去模拟,用Math.random()生成32位随机十六进制字符串就够了。但要注意的是,某些网站的nonce校验会要求全局唯一或者一段时间内唯一,用随机数一般没问题,但如果遇到“同一nonce不能出现两次”的严格校验,最好仿照原站使用带时间戳种子的随机算法。这里因为是常见场景,用普通随机数就行。
3.5 Python端算法移植与完整请求模拟
算法还原后,接下来就是把它移植到Python里。我习惯用requests库,配合哈希库和随机数模块实现完整的签名和请求逻辑。
import requests import time import hashlib import random def generate_nonce(length=32): chars = '0123456789abcdef' return ''.join(random.choice(chars) for _ in range(length)) def calc_sign(params, ts, nonce): sorted_keys = sorted(params.keys()) raw = '' for key in sorted_keys: raw += f"{key}={params[key]}&" raw += f"ts={ts}&nonce={nonce}" secret_key = "your_secret_key_placeholder" raw += secret_key return hashlib.sha1(raw.encode('utf-8')).hexdigest() def fetch_rank_data(): ts = int(time.time() * 1000) nonce = generate_nonce() params = {"page": 1, "size": 20} sign = calc_sign(params, ts, nonce) url = "https://example.com/api/rank" headers = { "User-Agent": "Mozilla/5.0 ...", "Referer": "https://example.com/rank" } params_ = {"page": 1, "size": 20, "ts": ts, "nonce": nonce, "sign": sign} resp = requests.get(url, params=params_, headers=headers, timeout=10) return resp.json()这段代码把前面的算法还原全部落到了实处。第5-8行生成了一个32位随机nonce,第10-16行严格按照排序拼接+拼接时间和随机数+拼接密钥的规则做SHA1摘要,最终拼出完整的请求参数。跑一遍请求,能正常拿到JSON数据的话,逆向核心工作就算完成了。
4. 常见问题与排查技巧实录
4.1 无限debugger反调试怎么绕过
几乎每个做JS逆向的都会遇到无限debugger。这个网站的排行页里也埋了这种反调试代码,表现形式是在代码里写了一个setInterval或者循环调用debugger语句,只要打开DevTools就会被反复断住,烦得不行。
我试过几种方案,最有效的是把debugger关键字在代码层面上屏蔽掉。Chrome DevTools的Sources面板里,右键点击代码行选择“Never pause here”,可以禁用当前行断点。但无限debugger一般不是写在某一行的,而是写在循环或setInterval回调里,每一轮都会触发,所以需要一点点地把所有相关行都禁用。
另一个更彻底的办法是,在DevTools里右键点击debugger语句所在行,选择“Add script to ignore list”,这样脚本运行时凡是命中该脚本的暂停都会被忽略。如果反调试代码是动态注入的,每次刷新位置都变,那可以考虑直接改代码,把包含debugger的代码片段从文件里替换掉。用Local Overrides功能可以覆盖网络请求中的JavaScript文件,把文件本地修改后再加载。具体做法是在Sources面板里打开目标JavaScript文件,右键选择“Overrides”里的“Save for overrides”,然后本地编辑文件,把debugger删除,保存后刷新页面就会加载修改后的版本。
4.2 加密参数在闭包内部,直接搜不到怎么办
有些网站不把参数逻辑写在全局作用域,而是包在IIFE或模块闭包里,直接全局搜索sign搜不到。这时候就要换一个思路,从网络请求触发点反查。
先给XMLHttpRequest.prototype.open和send方法挂Hook,在Console里执行:
(function() { var originalOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function(method, url) { if (url.indexOf('/api/rank') !== -1) { console.trace('XHR opened:', method, url); } return originalOpen.apply(this, arguments); }; })();这样当页面发起请求时,Console会打印完整的调用堆栈,顺着堆栈一层层点进去,就能找到发送请求的函数,再往上追溯加密参数的生成位置。这个方法对绝大多数前端框架都有效,因为Ajax请求最终都要走XHR或fetch,挂Hook是通用解法。
4.3 参数拼接方式不对导致签名校验失败
就算还原的算法是对的,也经常会遇到签名校验失败的提示。这种情况十有八九是拼接顺序或细节不一致导致的。这里分享几个高频的踩坑点。
- 参数排序规则不一定按字母升序,也可能是按照参数名ASCII码降序、或者按照参数在URL中出现的顺序。需要仔细看原始代码。
- 某些值为空的参数可能被过滤掉,不参与签名。
- 数组或嵌套对象序列化时,JSON.stringify与手工拼接结果差异巨大。
- 布尔值true和字符串"true"在拼接时可能不同。
- 时间戳可能使用秒级而非毫秒级,可能与nonce或其他字段组合后再参与签名。
遇到签名校验失败,建议在还原的代码里加几行log,把参与签名的原始字符串打印出来。然后在浏览器Console里同样打印一份,两者逐字符对比,找到差异后修正。
4.4 本地执行计算签名时报错“window is not defined”
这一条主要是想强调“浏览器环境”在加密逻辑中的重要性。很多加密函数虽然核心算法简单,但会在计算过程中引用window、navigator、document等浏览器全局对象。比如用window.screen.width参与指纹计算,或者用navigator.userAgent参与字符串拼接。
在Python里复现时,直接用hashlib计算会报错或算出完全不同的结果,因为环境变量缺失。解决方案有两种。一是把这些环境变量作为配置参数传给Python的签名函数,从浏览器Network面板里复制对应的值来填充。二是如果环境变量会动态变化,就需要用Js2Py、PyExecJS或Node.js子进程来在Python里直接执行还原后的JavaScript代码。我的个人建议是:能扣核心算法就扣核心算法,最好别直接用整个JS文件跑,补环境补到怀疑人生是常有的事。
5. 隐藏难点:反爬指纹与动态Cookie
5.1 指纹验证是怎么影响请求的
说完了签名,再来讲个更容易被忽略的难点。很多网站的排行接口不仅有签名参数,还要求请求头带上一个由前端动态生成的Cookie值,这个Cookie的生成和浏览器指纹强相关。Python端如果用裸的requests请求,即使签名算对了,服务端也有可能在Cookie校验环节就把你拦下来。
本次案例中,pp网的排行接口就存在这样的校验。它在请求前执行了一段脚本,读取canvas指纹、WebGL渲染器信息、屏幕分辨率、语言环境,甚至鼠标移动轨迹的综合特征,生成一个唯一标识写进Cookie。服务端拿到Cookie后,会校验这个标识是否存在且合理。如果你直接用requests访问,缺少这个Cookie,服务端就会判定为可疑请求。
对于这种情况,从纯requests层面去模拟是极其痛苦的,因为canvas渲染结果在不同设备上不一样,鼠标轨迹更是无法伪造。明智的做法是缩短攻击面:把可控的指纹参数固定下来,比如锁定User-Agent、屏幕分辨率、语言等,然后用Node.js或Selenium的CDP协议(Chrome DevTools Protocol)获取真实生成的Cookie,再交给requests使用。
5.2 用CDP获取真实Cookie的落地方法
用Selenium或Puppeteer加载页面,启动时会自动执行页面里的所有JS,包括生成指纹Cookie的那部分。等Cookie生成完之后,从浏览器上下文中把Cookie取出来,再交给requests或Scrapy去请求接口。
具体可以用Selenium来操作:
from selenium import webdriver from selenium.webdriver.chrome.options import Options options = Options() options.add_argument('--headless') options.add_argument('--disable-blink-features=AutomationControlled') driver = webdriver.Chrome(options=options) driver.get('https://example.com/rank') cookies = driver.get_cookies() cookie_str = '; '.join(f"{c['name']}={c['value']}" for c in cookies) print(cookie_str) driver.quit()拿到这串Cookie后,后续的requests请求直接把它塞进Headers里。这种方式的好处是:指纹是真实浏览器环境生成的,过了服务端的环境校验;而数据获取仍然走高效的requests,不用在Selenium里做解析和翻页,速度优势依然保留。
5.3 为什么有时候用浏览器直接访问正常,但requests就异常
很多人在这一步会陷入一个误区:浏览器里明明能看到数据,requests里却拿到空结果或验证码。核心原因是,前端签名只是第一层防护,后端往往还会校验请求头里的User-Agent、Accept、Referer、Origin等字段是否真实一致,以及Cookie是否有效。如果这些字段和浏览器环境不一致,即便签名算法完全正确,请求依然可能被判为异常。
我的做法是,在DevTools Network面板里找到一个成功的请求,把它的Headers整体复制出来,和requests代码里的Headers逐项比对。常见差异包括少带了Accept-Language、Accept-Encoding、Upgrade-Insecure-Requests等。把这些缺失的字段补上,成功率会明显提升。
6. 进阶思考:对抗升级与应对思路
6.1 加密强度升级的常见方向
做完这个案例之后,我不禁想聊聊这个领域的趋势。现在的大型网站已经很少用单一静态密钥+摘要算法来做签名了,很多都在向“动态密钥+非对称加密+行为验证”的方向演进。
所谓动态密钥,就是每次会话或者每次请求都重新生成一把密钥,密钥本身也通过非对称加密传输。这种情况下,即使你逆向出了某一次的签名算法,也无法长期复用,因为密钥已经变了。对抗这种机制,通常需要在浏览器环境里动态执行JS获取签名,或者用RPC调用浏览器内部函数。
所谓行为验证,就是引入滑块、点选、无感验证等,判断操作者到底是人还是程序。这一层对传统静态爬虫的打击是毁灭性的,因为行为数据很难模拟。绝大多数情况下,行业内的做法是引入浏览器自动化或真机设备池来绕过。
6.2 逆向代码的可持续维护策略
还有一点想单独拎出来说,就是“逆向代码的可持续维护”。很多初学者逆向成功一个网站后,就把代码扔进生产环境里一直跑。但网站前端工程师不可能坐视不理,他们会定期更新加密逻辑,可能是一次大版本升级直接改掉算法,也可能是做一次混淆升级导致参数位置变化。等到你某天发现数据抓不到了,再回头排查,往往已经过去了很久,损失已经造成。
我自己的习惯是,代码里加入签名算法版本检测和告警。每次请求观察sign值长度、接口返回的错误码有没有变化,一旦出现“签名校验失败”或者接口结构变更,日志系统第一时间告警。同时定期用真实的浏览器环境跑一遍流程,确保逆向结果与新版本页面没有偏差。这套机制能帮你在网站更新后最短时间内感知到问题,从而快速跟进新的逆向工作。
7. 踩坑心得与写在最后的实用建议
最后分享几个这次项目里踩过的坑,以及沉淀下来的通用方法论。
第一个坑是“过度关注混淆代码本身”。初学的时候容易陷进混淆代码的海洋里出不来,一个函数一个函数地啃,效率极低。后来发现,与其硬啃混淆代码,不如多抓几遍包、多下几个断点、多对比几次参数变化。数据流是会说话的,只要顺着数据流走,再深的混淆也藏不住核心逻辑。
第二个坑是“低估了环境校验的复杂度”。签名算法只占整个反爬体系的一部分,很多时候真正卡住你的是指纹校验、Cookie合法性、请求头不一致这类细节。拿到接口数据前,至少要在浏览器里成功请求过一次,然后把请求头和Cookie整体搬到代码里,先保证能通,再去优化动态获取Cookie的策略。
第三个坑是“忘记合规与授权”。这里必须多说一句。JS逆向属于技术对抗,可以做、可以学、可以用于授权范围内的安全测试和数据分析,但千万不要把它用在非法获取他人数据、恶意攻击等场景上。你逆向的网站如果是自己的、或已获得合法授权的,那没问题;如果是别人的,一定要仔细阅读服务协议和相关法律法规。尊重网站的服务条款,合理控制请求频率,不把逆向能力用于破坏和牟利,这是一个从业者最基础的底线。
再分享一个小技巧:全局搜索加密参数时,如果同一个关键词命中太多结果,试着搜索它出现的“赋值语境”,比如搜索"sign="、"sign:"、"sign\":"这类带符号的字符串,能瞬间过滤掉大量无关噪声。
这个案例本身不大,但技术栈相当完整,从抓包到断点调试,从算法还原到环境模拟,再到反调试绕过和Cookie获取,几乎覆盖了JS逆向的所有核心环节。方法论沉淀下来之后,换任何一个类似的网站,你都可以沿着“抓包定位参数、断点回溯逻辑、还原算法、模拟环境、验证请求”这条路走一遍,大概率能跑通。