你是不是也遇到过这种场景:打开某个数据页面,肉眼可见内容是残缺的,F12一看,请求参数里挂着一串看不懂的签名、时间戳、加密串,不传这些参数后端直接甩你一个403,传了吧又不知道它们怎么算出来的。更头疼的是,就算你把整段JS翻个底朝天,找到了加密函数,复制到本地一跑,结果跟页面上对不上。
这篇文章就是来把这套流程讲透的。不绕弯子,直接进入主题。
1. 加密参数是怎么出现的:先搞懂攻防双方的出发点
要破解一套加密参数,最先要做的事不是翻代码,而是搞明白为什么会有它。去掉“加密”这层神秘外衣,它本质上就是一套客户端与服务器之间的约定:你必须在请求里带上某个东西,证明你不是坏机器人,证明你知道某种规则,证明你没有篡改关键字段。
1.1 后端视角:我在防什么
防的不是单个爬虫脚本,而是批量、自动化、无差别的抓取。一个正常用户访问网页,浏览器会加载HTML、JS、CSS,然后执行脚本生成请求参数。而一个爬虫脚本通常是直接构造HTTP请求,跳过了“加载并执行页面JS”这个环节。
于是后端就想了一招:既然正常用户一定会执行JS,那我就在页面里塞一段JS,让这段JS计算出一个动态参数(比如签名、token、加密过的query),我再在服务端验证这个参数。如果请求里没有这个参数,或者参数的值不符合计算规则,那这个请求大概率不是正常用户发出的,拒绝掉。
拿一个实际例子来说,某新闻列表页,每次刷新页面,Network面板里都会多一条这样的请求:
GET /api/list?page=1&sign=7c4a8d09ca3762af61e59520943dc264你把这个参数删掉再请求,返回的就是HTTP 403。你把sign改成任意字串,还是403。只有那个看起来像MD5的字符串能用。这就说明服务器端有一个验证函数,它会用约定好的算法加上你请求里的其他字段重新计算一遍sign,比对一致才放行。
1.2 前端视角:我要算出一个什么东西
上面这个场景里,前端要做的事就是:在页面加载时,根据当前时间戳、请求参数内容、可能还有某个写死在JS里或后端下发的密钥,算出sign。然后把sign放进请求里发出去。
用一句话概括:参数加密的核心就是“双方共享一套计算逻辑,客户端负责算,服务端负责验”。而我们要做的事,就是把这套计算逻辑从JS里完整地抠出来、理解透、然后在自己写的代码里重新实现一遍。
特别要注意的是,这种“加密”绝大多数情况下不是密码学意义上的安全加密,而是一种基于“遮蔽性”的验证逻辑。也就是说,它不是不能被逆向,而是希望通过增加逆向成本,让自动化采集失去性价比。搞明白这一点,心态就能摆正:我们不是在破解某种坚不可摧的密码体系,而是在分析一段代码、找出一条计算路径、重现场景。
2. 参数定位:四层递进,迅速锁定加密函数
定位加密参数是整个流程里最考验耐心的一步。很多新手倒在这一步,不是找不到代码,而是被庞大的JS文件体积和错综复杂的调用关系绕晕了。我的习惯是把定位过程分成四层,一层一层往里走。
2.1 第一层:从请求调用栈反向追踪
这个方法适用于加密函数在浏览器执行栈里能被直接看到的场景。具体操作是:打开F12,在Network面板找到那条带加密参数的请求,右键,选择“Call Stack”或者直接点击Initiator列,看请求是从哪个函数发出的。
如果运气好,你会直接看到一个可点击的JS函数名,点击跳转到Sources面板对应的代码行,加密逻辑就在附近。比如某平台的列表接口,发起请求的代码长这样:
function buildUrl(path, params) { var sign = generateSign(params); var fullUrl = path + "?" + serialize(params) + "&sign=" + sign; return request(fullUrl); }看到这个generateSign没有?它就是你这一层要找到的目标。但很多时候,点击Initiator只能看到一个被压缩过、没有任何命名的函数,比如i()、c()、t()这种,这时候就要用下一层方法。
2.2 第二层:全局搜索关键词,缩小代码范围
当调用栈追不下去的时候,最朴素也最有效的方法是搜索。先在Sources面板里搜索这些关键词:
sign(最常见的参数名)tokenparam、paramsencrypt、decryptmd5、sha1、sha256、aes、des、rsa- 请求参数里那个奇怪参数的名字本身,比如
ticket、sig、key
搜索是有技巧的。如果你搜sign,可能会跳出几万个结果,这时候要叠加条件:看一下搜索结果里哪些文件是当前页面实际引入的,哪些函数在被调用时传入了跟请求参数同名的变量。另外,压缩过的库文件一般在开头会有一段!function(e,t){...}(...)这样的包裹结构,真正的业务代码往往在整段压缩代码的尾部或中间某个不显眼的位置。
我还是用刚才那个新闻列表的例子。搜索“sign”,发现某个文件里有这样一行:
sign = hex_md5(a = "" + timestamp + nonce + query)到这一步,加密函数的入口其实已经出来了:hex_md5()接收的参数是timestamp + nonce + query的拼接字符串。但我们还不能高兴太早,因为还需要确认时间戳是谁生成的、nonce又是哪里来的,以及query是原始字符串还是经过编码后的字符串。
2.3 第三层:XHR断点监听,留住调用上下文
如果搜索也找不到,或者说找到了但逻辑被拆分得稀碎,那就上XHR断点。在Sources面板的右侧,找到XHR/fetch Breakpoints,添加一条你要拦截的请求URL片段,比如/api/list。设置完之后刷新页面,浏览器会自动在请求发出的那一刻暂停在发起请求的JS代码处。
暂停之后,看Scope面板。这里面有当前函数能访问到的所有变量,包括局部变量、闭包变量、全局变量。你可以在Console手动执行一些小实验,验证当前上下文中某些变量的值是不是和请求参数里的一样。如果一样,说明加密逻辑能接触到这些数据,继续往函数外部找它的计算来源。
这个方法的优势是直接把执行现场冻结住,不给你猜测的余地。你可以逐行查看执行流程,找到加密函数之后,在Console里直接调用它试值。
2.4 第四层:针对代码混淆的反定位手段
代码混淆是这条路上常见的大山。典型的混淆手段包括:变量名替换成无意义字符串(如_0x4b2a)、字符串加密存储、控制流平坦化。第一眼看上去,整段代码像天书。
应对混淆,技术手段有几个,但核心心法是“只看参数流向,不管代码长相”。意思是说,你不必理解每一行混淆代码的含义,只需要找到数据从哪个变量进来、经过哪些处理、最后输出到哪里。具体做法是:
- 在发起请求的函数调用处打上断点(如果函数被混淆到没有名字,可以在搜索结果里跳到那一行手动加断点)。
- 在请求开始时,记录关键参数的初始值。
- 逐行执行(F10)或跳入函数(F11),观察参数值变化的关键节点。
- 遇到明显在做字符串拼接、数组按位异或、位运算一类操作的代码段,放慢速度仔细看输入输出。
再补充一个技巧:如果JS里有很多_0x开头的变量名,可以先用浏览器的格式化功能(Sources左下角的大括号图标)把压缩代码展开,然后复制到本地编辑器里,用全局替换的方式把_0x4b2a这种变量改成a、b、c,可读性会瞬间提高不少。
3. 加密机制拆解:从一次具体的逆向流程说起
定位到了加密函数,接下来要做的就是一条条捋清楚它的内部实现。这个环节最容易出错,因为一个加密参数往往不是由单一算法算出来的,而是多个步骤串起来的结果。下面用一个我实际逆过的演示型案例来讲(对原文数据做了脱敏处理,但结构完全一致)。
3.1 完整示例:从源码到请求参数的还原过程
页面上的请求长这样:
POST /api/search Form Data: keyword=手机 page=1 pagesize=20 ts=1701234567890 sign=4f8f7a2a3b39c1d9e6e9d0c7b6f8a3f1我在全局搜索里找到一条线索,某个文件里有"sign"字样,点击进去,格式化后看到的加密逻辑是这个(伪代码):
function getSign(ts, page, keyword) { var raw = "keyword=" + keyword + "&page=" + page + "&pagesize=20&ts=" + ts; var key = getKey(); // 这个key是动态的 var first = CryptoJS.HmacSHA256(raw, key).toString(); var second = CryptoJS.MD5(first + "static.salt").toString(); return second; }看到这段代码,心里要有个谱:这套签名流程一共三步——先用HMAC-SHA256计算第一层摘要,再横向拼接一个固定salt后做MD5,最后输出小写十六进制字符串作为sign。
接下来验证getKey()函数。它从接口返回的config配置中读取一个字段,并通过一个动态规则变化,比如每天切换一次。为了确定key的当前值,可以在调试中断住,然后在Console里执行getKey()看输出。拿到以后,在Console里自己拼一遍:
var key = getKey(); var raw = "keyword=" + "手机" + "&page=" + "1" + "&pagesize=20&ts=" + "1701234567890"; var first = CryptoJS.HmacSHA256(raw, key).toString(); var second = CryptoJS.MD5(first + "static.salt").toString(); console.log(second);输出等于请求里的sign,说明我们的理解是对的。这里有个细节:代码里page和pagesize是数字类型,但拼接的时候它们被隐式转成了字符串,所以你要直接用字符串拼接,不要自己额外做类型转换。
3.2 算法识别的几条经验:MD5、SHA、HMAC与加盐特征
光知道代码逻辑还不够,你还需要能迅速识别算法类型,因为不同算法对应了不同的验证方式。实际工程中,判断依据通常有三个:
- 摘要长度:MD5是32位十六进制,SHA-1是40位,SHA-256是64位。看一眼参数长度就能排除大部分可能性。
- 使用方式:直接对字符串做哈希的,比如
md5(raw),是最常见的;如果还涉及密钥,一般是HmacSHA256(data, key)这类算法。 - 盐的使用:很多实现会在哈希前或哈希后拼一个固定字符串,这个字符串可能是明文,也可能是经过编码的。如果发现代码里有
+ "xxxxxxxx"这种操作,基本就是在加盐。
建议在平时积累一个“算法识别速查表”,看到输出长度、看到函数名就能快速反射,节省大量走弯路的时间。另外,很多加密库会用算法名作为函数名,比如CryptoJS.MD5、forge.sha256、jsrsasign等,直接在全局搜算法名,能更快找到关键代码。
3.3 动手还原本地实现:推荐的库和标准写法
还原的时候,我推荐优先用现成的库而不是自己写算法实现。Python侧常用的有hashlib(原生支持md5、sha系列)、hmac(支持任意HMAC算法)、pycryptodome(提供AES、DES、RSA这类对称非对称加密能力)。
拿刚才的例子,Python还原长这样:
import hashlib import hmac import time def get_sign(keyword, page, pagesize, ts): raw = "keyword={}&page={}&pagesize={}&ts={}".format(keyword, page, pagesize, ts) first = hmac.new(key.encode(), raw.encode(), hashlib.sha256).hexdigest() second = hashlib.md5((first + "static.salt").encode()).hexdigest() return second ts = str(int(time.time() * 1000)) sign = get_sign("手机", "1", "20", ts)注意几个细节:(first + "static.salt").encode()里,first是十六进制字符串,和salt之间没有分隔符,直接拼接,这点要和JS端对齐。再比如有些JS里会在拼接前做encodeURIComponent,那你在Python里也要用urllib.parse.quote对应处理。每一个看似多余的小步骤,都会影响最终结果。
3.4 工具链选型:浏览器F12、脚本调试器与请求库的组合拳
好的工具能让这个过程事半功倍。我平时的工作组合是:
- 浏览器F12的Sources面板做动态调试,利用断点、Watch、Console即时验证。
- 把自己推算出的代码放到本地脚本里跑,拿输出的sign与页面请求里的sign做自动比对。
- 用请求库(比如Python的
requests或httpx)模拟发送带参请求,验证服务器端是否接受我生成出来的参数。
如果页面JS比较复杂,还会捎带用上支持自定义执行JavaScript的环境,比如在Node.js里把关键JS函数提取出来单独执行,或者在Python里嵌入一个JS执行引擎来跑原始加密函数。这个方案有一个好处:如果加密逻辑非常复杂,与其翻译,不如直接把原JS函数当黑盒使用,传参进去拿结果。
4. 请求模拟落地:加密参数只是起点,完整复现请求流程
很多人到了第三步,觉得自己已经“破解”了加密参数,立刻就去写爬虫脚本。结果一跑,不是被拒绝,就是数据不对。原因很简单:一个正常的页面请求不仅仅包含一个加密参数,还涉及请求头、Cookie、请求顺序、甚至可以会有二次签名的字段。你只解决了参数,没解决请求的完整上下文。
4.1 请求头里容易遗漏的上下文字段
浏览器发请求时,会自动带上固定的请求头集合。有一些后端逻辑会校验这些头的存在性,就算不校验,你也应该尽量模拟得和真实浏览器一致。
关键字段通常有这些:
User-Agent:标识浏览器版本,后端可能会要求它包含特定关键字。Referer:有些接口要求请求必须来自某个页面,否则直接拒绝。Origin:跨域场景下校验用。Accept-Language:部分站点会检测语言偏好。Cookie:整个浏览器身份的载体,包括登录状态、会话信息、补环境参数等。
我的建议是:先通过F12复制浏览器的完整请求头,一条条对应到你的请求代码里,不要上来就只带四五个“核心”头,宁可多带几个不会变动的静态头,也不要漏掉关键动态头。
4.2 请求顺序与前置接口条件
有些站点会要求必须先请求某个配置接口,拿到动态密钥之后才能构造正式数据请求。这个顺序如果被打乱,即使你签名算法完全正确,服务器端也会检测到异常流程直接拒绝。
比如搜索接口的请求可能在正式发出去之前,需要先访问一个/api/config接口,获取本次会话使用的salt值。这个salt每天凌晨刷新,且每次会话绑定一个随机nonce。如果你跳过配置请求,直接用固定salt去计算sign,即使算出来也可能通不过校验。
处理这类问题的策略是:把前置接口的产生逻辑也纳入模拟范围。具体做法是先用浏览器正常流程访问一次页面,记录下关键前置请求及其返回内容,再在脚本里按同样的顺序依次调用。
4.3 Cookie与回话状态管理:登录态与临时Cookie的处理
谈到Cookie就绕不开登录态。如果目标数据在登录之后才能访问,那你必须在请求模拟里加入登录流程。这里权当提醒一句:哪怕你只是在本地自用,也建议优先登录个人账号,不要用批量注册的账号,因为批量注册基本等于在脸上写着“我不是真人”,只会换来更高强度的风控。
Cookie的存活周期和刷新机制也很重要。有的站点Cookie里有一个sessionid七天后失效,有的还包括一个XSRF-TOKEN每次请求都要携带且不断刷新。建议在脚本里实现Cookie的自动保存与更新,每次请求之后检查返回头里是否携带了Set-Cookie,有就更新本地Cookie池。
4.4 请求重放与校验逻辑验证
写完了请求模拟,最直接的验证方式不是跑通一次就完事,而是做重放测试:用同一套参数连续请求5到10次,观察结果是否稳定。这一步能暴露出很多隐性限制,比如接口是否做了请求频控、签名是否绑定了时间戳有效期、Cookie是否会在短时间内失效。
注意观察返回状态码:
- 200:正常。
- 403:大概率签名错误或请求被风控。
- 412:一般表示前置条件未满足,比如缺少前置请求。
- 429:访问太频繁,触发频控。
5. 常见坑和排查思路:只有亲手踩过,才知道这些细节有多致命
这部分是全文含金量最高的地方。每一条都是我在实际项目中血泪总结,常规文档根本不会告诉你这些。
5.1 字符串拼接方式不一致
这是最隐蔽也最坑的差异。前端JS拼接参数时,数组、对象、字符串混拼到一起,中间可能有一层隐式转换,比如数组会自动转成以逗号分隔的字符串,对象会变成[object Object]。如果你在脚本里手写的拼接逻辑和JS默认行为不一致,sign必然对不上。
解决办法:把前端拼好的原始字符串通过断点截出来,直接比对你自己脚本里生成的中间字符串。两者一致,再进行下一步哈希。不要在中间过程靠猜。
5.2 字符编码与URL编码的差异
中文、空格、特殊符号在URL里要不要编码?编码成%XX还是+?这些都是影响签名的危险变量。前端JS里如果用了encodeURIComponent,处理中文时会输出UTF-8百分号编码;而Python的urllib.parse.quote默认不会编码某些字符,设置safe=''才能对齐。空格更是重灾区:JS的encodeURIComponent会把空格编码成%20,而有些语言默认会当成+输出,这就需要你明确指定编码规则。
5.3 时间戳的时区与取值方式
时间戳一般有两种:精确到秒的十位数字,以及精确到毫秒的十三位数字。后端验证时的容错窗口可能是正负几秒,也可能是正负几分钟。如果你在脚本里用的是请求发出前生成的时间戳,但代码计算时和请求发出之间隔了其他网络请求,时间戳与请求实际到达时间不一致,就可能被判定过期。
另一个隐含坑是,有些网站并不是拿客户端当前时间生成时间戳,而是用服务器下发的系统时间。实现方式是先请求一个时间接口拿到服务端当前时间,再用这个时间去计算签名。如果忽略了这一点,用自己的本地时间计算出来的签名自然永远匹配不上。
5.4 补环境与JS调用异常
如果把JS函数整个抠出来,放到Node或Python的JS引擎里跑,经常会遇到运行时报错:某某变量未定义、某某对象不存在。这是因为原函数依赖浏览器环境里的window、document、navigator等全局对象。你说函数本身没逻辑上依赖它们,但它内部间接依赖的某段初始化代码就引用了这些对象。
最实际的解决办法是给运行环境补一个简单的“环境代理”,定义这些对象的最小可用结构。比如:
var window = {}; var document = {}; var navigator = { userAgent: "Mozilla/5.0 ..." };如果某个库检测了更细的数据,比如补WebGL相关的canvas指纹,那工作量大一些。但大多数场景不需要做到这个级别,只要环境不报错、函数能正常返回结果就行。
还有一个对应的经验:与其花大力气翻译一个数百行的加密函数,不如直接让原版JS函数在可控环境里自我运行。利用现成的JS执行链可以直接调用加密函数,传入参数拿返回值。
5.5 完全看不到加密函数怎么办
极少数场景下,页面会使用WebAssembly来承载核心加密逻辑。你在JS代码里找不到加密函数,因为它被编译成了wasm模块的二进制代码,JS侧只负责传入参数和接收返回值。
应对思路是:不要试图真正“反编译”wasm。更高效的方案是直接调用那个封装好的JS入口函数,把它当做黑盒。它接收什么入参,你就给什么入参,它返回什么结果,你就用什么结果。换句话说,你的脚本不关心加密内部怎么做,只和它交互即可。
5.6 踩坑速查表
| 常见问题 | 可能原因 | 快速排查方式 |
|---|---|---|
| sign总是对不上 | 拼接顺序或拼接内容与JS不一致 | 在断点里获取中间字符串并比对 |
| 返回412 | 缺少前置请求步骤 | 检查请求顺序,优先加载配置接口 |
| 请求总是403 | Cookie缺失或过期 | 检查Cookie池是否基于最新会话构建 |
| Python与JS的hashlib结果不一致 | 编码方式不一致 | 统一UTF-8编码,检查URL编码细节 |
| 某环境补了还是报错 | 缺少特定全局对象属性 | 用try...catch定位报错点再针对性补 |
| 签名正确但接口还拒绝 | 请求频控或UA非法 | 降低请求频率,检查User-Agent是否完备 |
6. 定位能力体系:从会追参数到能处理整套请求链
很多人做到“能跑通一个接口”就停了,再遇到一个同类型但加密方式完全不同的站点,又开始从头追参数。这种低水平的重复没有价值。真正应该沉淀下来的,是一套通用的定位与复现能力。
6.1 建立你自己的请求链拆解模板
每次分析一个站点,都可以用固定模板梳理信息。我的模板长这样:
- 目标URL与请求方法
- 完整请求参数列表(含哪些动态生成、哪些静态固定)
- 前置请求列表(配置接口、时间接口、会话初始化接口)
- Cookie依赖关系
- 加密函数入口与算法类型
- 中间字符串拼接规则
- 验证方法和结果
这套信息整理出来之后,下次即使目标站点变了,分析路径依然顺畅,因为你不依赖“记住某个具体站点”的代码细节,而是依赖“如何拆解一个站点”的方法论。
6.2 日常积累加密算法识别技能
见多识广是提速的根基。建议平时编写代码、阅读开源项目时多积累算法识别经验。看到一个32位字符串字符串,应该立刻想到MD5;看到64位字符串,就要考虑SHA-256或者HMAC-SHA256可能。再结合length的长度,配合经验判断算法类型基本不跑偏。
6.3 技术之外的合规与边界意识
掌握这些技术,真正在日常工作中发挥价值的场景,是我自己维护的数据产品需要对接正规授权接口时,用来理解请求参数的构成,排查联调问题。或者在公司内部系统做自动化测试时,需要模拟前端请求完成流程回归。在这些场景下,加密参数只是系统的一部分,而“逆向”是系统设计的一部分,这本身也是开发与测试工作里的正常一环。
务必确认三点:第一,你在使用的数据源允许你这样做;第二,你抓取的数据不涉及个人身份信息或受保护数据;第三,你的请求频率控制在用户行为合理区间。这里多说一句,任何自动化访问都不能无限加大频率,这是基本的网络礼仪,也是一个工程师底线的体现。
7. 写在最后:我的实操体会
做了这么多年的请求分析工作,一个很深的感触是:多数人败在急躁。拿到一个站点就想三步之内搞定,跳过了仔细阅读请求链、对比中间变量这些环节,最终时间都耗在猜上面。与其不断试错,不如先花20分钟把请求链的完整链路梳理出来,再动手去写模拟脚本。理顺了再动手,动手就成功,基本是这个道理。
另一个值得养成的习惯是保留好你每次分析时记录的中间过程和经验笔记。加密参数机制千变万化,但底层思路高度重复。你今天认真记录的某个拼接规则,很可能在三个月后遇上一个高度相似的场景时直接照搬使用。那时候你会感谢现在细心整理笔记的自己。