我先把结论放在前面:别看“私募pp网-私募排行js逆向”这标题像个选股工具测评,其实它是个纯正的前端加密对抗实战项目。核心不是私募数据本身,而是“排行榜页面的数据是加密接口吐出来的,怎么把这个加密过程还原出来”。完完整整走完一遍,你至少能掌握XHR断点、调用栈回溯、混淆JS还原、Node补环境这几板斧,往后遇到更复杂的加密接口,思路会清晰很多。
这篇博文就按我当时做这个项目的顺序来写,从最开始的抓包定位,到断点找入口,再到还原加密函数,最后用Python把整条链路串起来。中间那些踩过的坑、误判过的方向,我也会一并写出来,免得你重复交学费。
1. 逆向目标与前期观察
1.1 先说清楚要抓什么
这个项目的目标很明确:某家做私募产品展示的垂直网站,里面有个“私募排行”栏目,按收益、回撤等指标对产品排序。页面本身能看到数据,但直接请求接口返回的是一团乱码,加密字段一个叠一个,没法直接用于分析。
我习惯把这类逆向项目分成三层:
- 第一层是“看得见的”:浏览器里渲染出来的排行榜表格,字段名、排序规则、前端展示逻辑;
- 第二层是“藏在请求里的”:Network面板里那个XHR请求,请求头、query参数、request payload;
- 第三层是“真正干活的”:JS代码里生成签名、加密参数的逻辑,一般隐藏在压缩混淆的chunk文件里。
这次的核心矛盾出在接口的两个请求参数上:一个类似sign的签名值,以及整个响应体被做了编码处理。不解决这两个点,哪怕你把Cookie、User-Agent全部带齐,服务器照样返回非200或者一团乱码。
1.2 打开开发者工具快速定位请求
推荐直接用Chrome DevTools,不推荐一开始就上抓包工具。原因很简单:我们面对的是HTTPS站点,Fiddler或Charles即使配好证书,有时候也会被前端指纹检测干扰;而DevTools是浏览器自带的,天然在“可信环境”里,干扰最少。
实际操作步骤:
- 进入排行榜页面,按F12打开DevTools,切到Network面板;
- 勾选Preserve log,防止页面跳转刷掉请求记录;
- 手动刷新页面,等列表数据加载完毕;
- 在Network面板过滤XHR,找返回内容带产品净值或收益率的请求。
这里有个小技巧:不要急着看请求头,先看请求入口。在Network面板的请求列表里,把鼠标移到某个可疑请求上,会显示Initiator链。我们直接点进去,能看到是页面里的哪个JS文件发起的请求、对应的调用栈是什么。这一下就能省下很多全局搜索的时间。
我这次遇到的请求,URL大概是/api/rank/list?page=1&size=20&sort=profit&asc=0,但后面跟着一长串&sign=...&ts=...&nonce=...之类的参数。一眼就能判断,这是典型的“动态签名”模式。
2. 定位加密参数:从网络请求到JS调用栈
2.1 遇到底层加密参数怎么办
请求头里的参数不是我们要找的核心。真正要关心的是query参数里那几个看起来随机的东西。比如sign、ts、nonce,它们往往是一个组合。
判断优先级的方式很简单:
- 时间戳类参数(比如
ts=1691234567890),一眼能认出来; - 随机串类参数(比如
nonce=7f3a912b...),大概率是UUID或随机字节的十六进制表示; - 签名类参数(比如
sign=da3c8f...),最头疼,通常是前面多个参数拼起来之后做摘要算法。
这次三个都占全了。
我当时的策略是:先不管签名算法是什么,先去全局搜索“sign”这个字符串。在Sources面板里按Ctrl+Shift+F全局搜索,搜出来的文件可能很多,怎么缩小范围?
- 排除所有
.min.js里的source map注释行; - 优先看主入口文件(webpack的runtime、app.bundle.js之类);
- 搜的时候带上关键字上下文,比如
sign:、sign =、setRequestHeader('sign'。
全局搜索的好处是能直接找到赋值语句的源头。坏处是压缩混淆后的代码里,你会搜出来上百个匹配项,挨个看会疯。
2.2 关键词搜索定位加密函数
我当时没有死磕全局搜索,我用了更直接的XHR断点方式。
操作流程:
- 切到Sources面板,右侧Breakpoints区域打开XHR/fetch Breakpoints;
- 点击加号,输入URL中的关键字片段,比如
rank/list; - 刷新页面,脚本会在发起XHR请求前自动断住;
- 这时候右侧Call Stack就是金子,一层层点进去找自己写的业务代码。
断住以后,我通常先看Scope区域,找包含所有参数的那个对象。比如config、requestData、body,这个对象里通常能看见明文参数和那个sign值。然后沿着Call Stack往下走,找最近的那个非第三方库函数。
我记得当时断住的位置是在一个叫ajax的封装函数内部,参数已经拼好了,就差被加密。往上一层看到调用方是类似fetchRankList的方法,里面有一行代码非常关键,大致是这样:
var params = buildParams({ page: page, size: size, sort: sort, asc: asc, ts: Date.now(), nonce: generateNonce() }); params.sign = signParams(params);看到这里,基本可以确定加密入口就是signParams。接下来只需要搞清楚两件事:
generateNonce生成的是什么;signParams内部用了什么算法,摘要的输入格式是什么。
3. JavaScript逆向实战:还原加密逻辑
3.1 分析混淆代码时别慌,先做格式化
定位到signParams函数以后,我先把那段JS源码完整复制出来,放到本地一个临时文件里,用Prettier格式化了一遍。这一步强烈建议做,压缩后的代码经常是一整行,人眼看不出嵌套关系,格式化之后才看得清函数边界和变量作用域。
格式化完,这个函数的结构基本清楚了:
function signParams(params) { var keys = Object.keys(params).sort(); var str = ""; for (var i = 0; i < keys.length; i++) { var key = keys[i]; var value = params[key]; if (value !== null && value !== undefined) { str += key + "=" + value + "&"; } } str = str.slice(0, -1); str += "&secret_key=X9sK2wR7"; return md5(str); }这里有几个典型特征:
- 先对参数名做字典序排序,这是签名算法的常见套路,目的是保证服务端拿到参数后按同样规则拼串能对齐;
- 把参数拼成
key=value&key=value的格式,结尾再拼一个固定密钥; - 最终做MD5摘要。
理论上到这一步,签名算法就明确了。但我不能光看这个函数就完事,因为页面上跑的JS是压缩混淆过的,真实场景可能还嵌套了rsa、aes、base64等操作。这次比较走运,只用了MD5。
但我还是要提醒一句:看到MD5别激动,很多网站现在会用MD5做第一层包装,后面再套一层AES,或者用自定义的字符替换规则。你需要继续确认md5这个函数是不是真的就是标准MD5。最简单的验证方法是找个在线MD5工具,用同样输入算一遍,对比结果。如果能对上,说明没有摘歪。
3.2 补环境绕过检测
光知道算法还不够,因为你不能保证signParams内部没有依赖window、document、navigator这些浏览器环境对象。比如有些加密函数内部会读取navigator.userAgent、document.cookie作为盐值。这时候直接扔进Node.js里跑,肯定会报错。
我的处理办法是“补环境”。先跑一遍,看报错:
node sign_logic.js如果报window is not defined,就在文件头部补一个最小化的window对象;如果报navigator is not defined,同样补一个。这次运气不错,signParams是纯函数,只依赖参数和固定的secret_key,所以不用补环境就能直接跑。
但为了演示通用流程,我建议你建立一套“最小补环境模板”,哪怕这次用不上,下次可能就用上了。模板大致长这样:
global.window = global.window || {}; global.navigator = global.navigator || { userAgent: "Mozilla/5.0 ... Chrome/120.0.0.0" }; global.document = global.document || { cookie: "", getElementById: function() { return null; }, createElement: function() { return { getContext: function(){ return null; } }; } };补环境的核心原则是:能不补就不补,补了就不要慌。只补报错涉及到的属性,千万别想着把整个浏览器环境模拟出来,那是浪费时间。
3.3 用Python调用还原后的加密函数
签名算法确认没问题之后,我把签名逻辑用Python重写了一遍。有人说“直接用Node子进程调用不就行了”,但这会引入额外依赖,而且在高并发请求下进程开销不小。对于这种简单MD5签名,用Python重写是最高效的方案。
Python实现代码:
import time import uuid import hashlib def generate_nonce(): return uuid.uuid4().hex def generate_sign(params): sorted_keys = sorted(params.keys()) raw_string = "" for key in sorted_keys: if params[key] is not None and params[key] != "": raw_string += f"{key}={params[key]}&" raw_string = raw_string[:-1] raw_string += "&secret_key=X9sK2wR7" return hashlib.md5(raw_string.encode("utf-8")).hexdigest() def build_params(page=1, size=20, sort="profit", asc=0): params = { "page": page, "size": size, "sort": sort, "asc": asc, "ts": int(time.time() * 1000), "nonce": generate_nonce() } params["sign"] = generate_sign(params) return params if __name__ == "__main__": print(build_params())这里我强调几个容易踩坑的细节:
ts参数我用了毫秒级时间戳,服务端判断过期时间通常也是按毫秒算的,如果用了秒级,很容易返回“签名过期”;nonce直接用UUID的十六进制字符串,去掉连字符,格式正好是32位,和网站自带的策略一致;- 排序参数
asc别写成布尔值。JS里true会被转成字符串"true",但Python里True会被转成"True",首字母大写,签名对不上。
当年我第一次跑的时候就栽在asc这个参数上。JS那边传的是数字0和1,我在Python里写成False和True,结果签名一直验证失败,排查了好久才发现是大小写问题。
4. 常见问题与排查技巧
4.1 浏览器环境与Node环境差异
这个坑在多数字签名场景里都会出现。浏览器里的JS自带window、document等对象,而Node.js里没有。如果你把一段依赖navigator.userAgent的加密函数直接搬到Node里,它就会报错。
我遇到的情况是:加密函数本身是纯函数,不依赖任何浏览器对象,但外层有个模块初始化代码,一加载就试图访问window.localStorage。遇到这种,最简单的办法是把那行初始化代码注释掉,或者提前声明一个伪localStorage对象。
global.localStorage = { getItem: function() { return null; }, setItem: function() {} };不要觉得这是作弊,逆向工程里“够用就行”是常态。只要能跑出和浏览器一致的结果,补丁越少越好。
4.2 动态加载与Hook技巧
有些网站会把加密逻辑拆成动态加载的组件,主入口里只有一个import(),然后异步拉取真正的加密模块。这种情况用XHR断点可能捕捉不到直接入口,因为请求发起者在异步回调里。
我的处理方式是Hook,这里推荐两个方向:
- Hook
window.fetch和XMLHttpRequest.prototype.open,把所有请求公共信息打印出来,快速定位发起时间点; - Hook
String.prototype的关键方法,比如replace、slice,因为很多加密函数会调用这些方法处理参数字符串,通过堆栈就能抓到调用链。
但Hook也有风险,如果网站做了原型链检测,你贸然改原型可能会导致页面直接崩溃。我一般只在调试阶段Hook,确认逻辑后立刻移除。
还有一点要注意:现在的网站非常喜欢用webpackJsonp机制。你在全局搜索某个关键字找不到时,可以尝试搜索webpackChunk相关变量。加密逻辑往往被单独打进一个chunk文件里,只有触发某个行为时才加载。解决办法是手动触发对应行为,比如切页、排序、点击查询,然后再去Network里找chunk文件。
4.3 请求频率与合规提醒
这部分不属于纯技术,但我必须写。
我见过不少新手拿到签名算法之后,直接写个循环每秒请求几十次,结果IP被风控封禁。正确的姿势是:
- 控制请求频率,加个
time.sleep(random.uniform(1, 2))之类的限速; - 尽量复用Session和Cookie,不要频繁新建连接;
- 对目标域名做robots检查,确认哪些路径允许爬取;
- 下载下来的数据只用于个人学习研究,不要用于商业用途,更不要二次公开传播。
另外提一句,逆向JS本身是中性技术,但不同站点的服务条款差异很大。我在做这个项目前,会查看该站是否有反爬声明。如果明确禁止了数据爬取,我会缩小抓取范围,只做少量验证测试,不做全量数据采集。
4.4 调试过程中的三个救命技巧
最后分享几个小技巧,都是本项目中实际帮上大忙的:
技巧一:巧用Console来验证加密结果
在DevTools里直接执行signParams({page:1}),会得到当前页面下的签名值。然后你在Python里跑同一条逻辑,对比两个结果。不一致的话,优先检查排序规则和类型转换。
技巧二:把response的编码逻辑也还原出来
这次响应体不是单纯的JSON,外面套了一层自定义编码。服务端把数据做了某种变换,前端拿到后再还原。针对这类情况,别急着从零看编码算法。先去全局搜JSON.parse,看它之前有没有经过其他方法加工。
我在这个项目里就是用“搜JSON.parse”的办法找到了解码函数。
技巧三:善用Source面板的Overrides
有些JS文件经过格式化和修改后,可以通过Overrides保存到本地。刷新页面时浏览器会用本地文件替换线上文件。这个功能很适合在验证签名逻辑时用:你可以在JS里手动console.log打印中间变量,然后观察真实请求。
具体操作不复杂:Sources面板左侧能找到Overrides标签,选一个本地文件夹,点击允许授权,然后在文件上右键选择Save for overrides,之后重新加载页面即可。调试完记得删除本地临时文件,否则会影响后续正常浏览。
写在最后的一点心得
这个项目从定位到跑通第一个接口,我大概花了两个晚上。第一晚全耗在断点和全局搜索上,始终找不到加密入口;后来切换思路用了XHR断点,才从调用栈里一下子揪出signParams。第二晚就是格式化代码、还原逻辑、用Python重写,顺手把响应解码也一并处理了。
如果让我重新做一遍,我会先花五分钟看请求调用栈,而不是一上来就全局搜索。调用栈给你的是“动态视角”,全局搜索是“静态视角”,对于动态生成的参数,前者高效得多。
另外,像“私募排行”这种偏垂直领域的数据站,前端加密通常不会上特别重的方案,但只要思路通了,任何加密都只是时间问题。过程中真正值钱的不是那个签名算法本身,而是你练出来的调试直觉。
最后提醒一句:做逆向要守住底线,别把爬下来的数据当成自己开发的宝贝去售卖,也别写什么“绕过反爬”的教程去教唆别人批量爬取人家核心数据。学技术,是为了遇到问题能解决,不是为了制造问题。