ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JS逆向实战:破解接口sign签名校验算法

JS逆向实战:破解接口sign签名校验算法 打开私募pp网的排行列表页榜单数据在浏览器里展示得清清楚楚接口地址、请求参数全都能在开发者工具里看到。可真要自己写代码去请求这个接口立刻就会被一个sign is invalid拍回来。这个场景对做过数据接口分析的人来说应该不陌生网页能正常看到数据但直接请求接口却拿不到问题就出在JS逆向这个环节上——服务器对请求参数做了一道签名校验而签名算法被藏在了前端JavaScript代码里。这篇文章我会以这个私募排行接口为例完整记录一遍JS逆向的分析过程从定位真正的数据接口、比对请求参数、断点调试定位加密函数到还原签名算法并用Python复现。重点不只是这个站点的sign怎么解而是遇到同类问题时的通用排查链路希望给刚接触JS逆向、或者被某个加密参数卡住的朋友一些可以落地参考的思路。1. 被一道sign拦住之前先搞清楚数据是在哪一步丢的很多人在JS逆向的第一步就搞错了方向上来就盯着混淆的JS代码死磕其实正确的顺序是先从网络请求层面把边界画清楚。1.1 从Network面板找到真正的数据接口打开pp网的私募排行页面按F12进入开发者工具切到Network面板勾选Fetch/XHR过滤然后刷新页面。这时候能看到页面加载过程中发出去的所有异步请求其中之一就是返回排行数据的接口。这个接口通常长这样GET /api/private-equity/rank?page1size20orderdesctimestamp1735000000signxxx只从URL看参数无非就是页码、每页数量、排序方式和两个动态字段。但有意思的是直接把整个URL复制到Postman里请求返回的结果不是数据而是签名校验失败的提示。这里先明确一点浏览器里能看到数据是因为页面里的JS已经帮我们生成了合法的签名我们自己请求时没有生成签名的逻辑所以服务器不认账。1.2 比对静态参数和动态参数区分“正常参数”和“加密参数”把同一个接口在刷新两次时发出的请求放在一起比对很快就能发现规律。为了直观我把模拟观察到的参数情况整理成了表格实际字段名以调试为准pp网的字段我做了脱敏处理参数名第一次请求第二次请求是否动态page11否size2020否orderdescdesc否timestamp17350001001735000200是sign482fe3...32位hex9a1c77...32位hex是这里能得出两个判断timestamp是当前时间的Unix时间戳这个很好验证拿自己电脑当前的时间戳对比一下就能确认。sign是一个32位的十六进制字符串每次刷新都不一样。32位十六进制输出、并且随其他参数变化这是典型的消息摘要算法特征大概率是MD5也有可能是某种自定义hash但优先往MD5方向推测。这一步做完目标就清晰了只需要搞明白sign是怎么算出来的其他参数都是直接明文给的。1.3 服务器校验签名的逻辑其实没那么玄乎先把原理说透后面调试的时候心里才有底。服务器收到请求后会用同样的参数、按照同样的规则自己拼一段字符串算出一个签名再和请求里的sign做比对。对得上就认为是合法请求对不上就直接拒绝。这个机制非常像快递上的防伪标签商家发货时把包裹内容和防伪码贴在一起用户收到后扫一下码就能验证包裹是否被动过。JS逆向要做的事本质上就是从前端代码里找到这个防伪码的算法把它复刻出来。2. 确定加密位置远离一上来就啃混淆代码的误区知道目标是sign之后下一步就是在JS代码里定位它的生成逻辑。这一步最忌讳的就是打开一个几千行的压缩JS从头读正确做法是利用开发者工具的搜索和断点功能精准定位。2.1 全局搜索关键词先按参数名搜切换到Sources面板按CtrlShiftFMac上是CmdOptionF打开全局搜索直接搜sign。说的就是它因为请求参数里的sign是明文出现在代码里的只要在某个JS文件里有给sign赋值的操作就一定能搜到。搜索结果通常会给出几十个匹配项其中大部分是无关的比如CSS里的sign类名、代码注释、或者某个函数名里的子串。这里有个筛选技巧优先看哪种文件类型.js文件的匹配项优先于.json、.html等文件中的匹配项。优先看匹配行里同时出现sign:或sign 的这说明是在赋值而不是在读取。如果把参数名搜不到就换一个思路搜接口路径的关键词比如/rank找到构造请求的地方再往上看请求参数是怎么拼的。实际调试中我是在一个叫做chunk-rank.js的文件里找到了下面这类代码var params { page: 1, size: 20, order: desc, timestamp: Date.now(), sign: getSign(config) };sign的赋值来自于getSign(config)那答案就呼之欲出了核心逻辑都在getSign这个函数里。2.2 下断点看运行时值让代码自己告诉你现在知道了getSign函数的存在但它在文件里长什么样还看不到。这时候不要急着读代码而是直接在sign: getSign(config)这一行打一个断点然后刷新页面。刷新后浏览器会执行到这一行并且停下来这时候打开右侧的Scope面板能看到当前作用域下所有变量的真实值包括config里到底传了什么。这一步的价值是把代码和运行时数据对应起来。纸上谈兵地猜config是什么不如直接看实际值一目了然。2.3 跟着Call Stack找到加密函数的入口在断点停住时右侧还有一个关键的信息区域Call Stack调用栈。它记录的是当前函数是从哪一层被调进来的一层一层往上点就能还原整个调用链路。我当时的调用栈大致是这样的getSign (chunk-rank.js:123) (anonymous) (chunk-rank.js:110) r (chunk-rank.js:100) (anonymous) (chunk-rank.js:95) Promise.then从下往上看请求的Promise回调触发了参数构造逻辑参数构造逻辑里调用了getSign。点击调用栈里的getSign选项就会直接跳到函数体所在的位置。到这里加密函数的入口就定位到了。3. 拆解签名算法还原加密逻辑的完整推导过程定位到getSign函数之后真正的重头戏才开始。这段JS代码可能会很友好地直接告诉你拼接规则也可能是混淆过后的一堆乱码需要慢慢抽丝剥茧。3.1 从一段真实风格的函数体说起为了方便讲解核心推导过程我这里把函数体的逻辑抽象成一个典型的签名生成模式实际调试中很常见。它的原始代码可能长这样function getSign(cfg) { var timestamp cfg.timestamp; var page cfg.page; var size cfg.size; var order cfg.order; var secret a1b2c3d4e5f67890; // 从某个配置项里取的固定盐值 var raw page page size size order order timestamp timestamp secret; return md5(raw); }拆解一下这段逻辑cfg里除了timestamp、page、size、order这四个数据参数之外还有一个secret。这个secret并不是后端接口通过请求返回的而是写死在前端代码里的固定字符串通常被称为“盐值”。把所有参数按照“参数名参数值”的形式用连接起来最后再加上盐值拼出一条需要签名的原始字符串。对这个原始字符串做MD5得到32位十六进制输出就是sign。这里有一个重要的反直觉点盐值虽然泄露在前端代码里但服务器依然能校验通过原因是签名的目的不是防篡改而是防“非浏览器环境下直接调用接口”。普通用户拿不到签名的生成逻辑也就没法伪造合法请求。但这种保护强度其实是有限的因为只要前端能执行逻辑就一定能被分析还原。3.2 为什么优先判断是MD5而不是SHA或HMAC识别hash算法主要看输出长度和计算上下文。MD5输出32位十六进制字符SHA-1输出40位SHA-256输出64位。这个接口的sign是32位所以第一反应是MD5或某种自定义的128位摘要。第二步看调用方式。在代码里搜一下md5关键词看看是直接引用了某个工具库还是自己实现了一个函数。pp网的这个函数直接调用了一个名为md5的全局函数参数就是拼好的原始字符串这就更加确认了MD5猜测。需要提一下的是有的站点不会直接用标准MD5而是在MD5之外又套了一层反转、加盐或者部分截取等变换。判断方式也很简单在自己电脑上按同样的拼接规则计算一次MD5和抓包里的sign对比一下一致就说明没有额外变换不一致就去函数体里看看多出来的操作是什么。3.3 遇到混淆代码时怎么处理并不是所有站点的getSign都这么直白。如果打代码风格来判断有的站点会故意把变量名改成_0xabc123这种函数体里看不到任何有意义的字符串。遇到这种混淆常见的如OB混淆情况有几个实用的应对办法先在搜索框里找字符串片段。比如拼接逻辑里一定有page或size这种片段搜到之后去断点看调用栈里传进来的参数。利用浏览器开发者工具的自带功能做代码格式化Pretty Print也就是点击{}按钮让压缩成一行的代码变成可读的多行格式。不要逐行读完整代码改为在参数生成后立刻断点。比如算完后会有个return语句在返回前断点看返回值和输入参数倒推变换过程。如果前面几种都不好使可以用二分法在可疑代码附近多加几个断点观察局部变量的变化。哪个变量在断点前后从“无”变成“有”重点逻辑就在哪一段。这个阶段的核心心法是逆向不是抄代码而是找规律。混淆可以乱掉变量名但乱不掉参数拼接顺序、hash算法和盐值这些必要的“骨架”。4. 用Python复现sign生成把JS逻辑完整搬过来定位到了算法逻辑接下来的事情就相对机械了用Python把这套规则复现出来然后请求接口验证。4.1 复现签名生成逻辑基于前面分析得到的拼接规则Python代码并不复杂。需要强调的是真实场景下的拼接顺序、盐值、参数列表要以实际调试中看到的函数体为准下面的代码是一个可参考的模板。import hashlib import time import requests BASE_URL https://example.com/api/private-equity/rank SECRET_KEY a1b2c3d4e5f67890 # 从JS代码中提取的盐值 def get_sign(page: int, size: int, order: str, timestamp: int) - str: 根据JS逆向得到的拼接规则计算 sign raw_string ( fpage{page}size{size} forder{order}timestamp{timestamp}{SECRET_KEY} ) return hashlib.md5(raw_string.encode(utf-8)).hexdigest() def build_params(page: int, size: int, order: str) - dict: timestamp int(time.time()) params { page: page, size: size, order: order, timestamp: timestamp, } params[sign] get_sign(page, size, order, timestamp) return params这里要特别提醒一个新手容易忽略的细节字符串拼接时类型要严格一致。JS里数字和字符串相加会自动做隐式转换但Python不会所以上面代码里用f-string把page、size、timestamp都转成了字符串。如果这里不转换直接拿整数去拼算出来的MD5和JS里的结果必然对不上。4.2 请求头里那些看起来不起眼的值算出了正确的sign并不能保证请求一定成功因为服务器可能还会校验其他信息。我实际调试时遇到的情况是第一次请求返回了403 Forbidden而不是签名校验失败。检查之后发现是请求头里少带了一个Referer字段。浏览器发请求时Referer是自动携带的但requests不会需要手动加上。headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Referer: https://example.com/rank, Accept: application/json, text/plain, */*, } resp requests.get(BASE_URL, paramsbuild_params(1, 20, desc), headersheaders, timeout10) print(resp.json())补齐请求头之后终于拿到了和浏览器里一样的排行数据。这一步看着简单但它在开发中很常见——接口通了不算完还要把请求头、Cookie这些环境信息都补齐才算真正跑通。4.3 验证签名规则对不对多组数据试一遍第一次成功返回后不要急着宣布完工至少要再验证两组数据改变order字段的值比如从desc改成asc确认签名能跟着变化并且请求成功。翻到第二页把page改为2确认多页请求都能正常工作。如果只测一组就通过很容易忽略间隔性故障。比如某些站点的盐值会定时更换或者不同接口用的盐值不同只在某个固定参数下验证通过换一组参数就暴露出问题了。5. 实际操作中容易踩的坑从校验窗口到风控触发在还原这套签名逻辑、并且连续跑了一段时间之后我总结出几个文档里不会明说、但实际开发中一定会遇到的坑。5.1 timestamp的校验窗口很多签名参数里带timestamp不只是参与签名计算服务器还会检查它的“新鲜度”。比如服务器规定时间差不能超过5分钟超过就拒绝。这就导致一个典型的坑如果一次性生成好一批请求、然后在几分钟后才发出去前面的请求全部会被判为过期。解决方案通常有两种每发一个请求就重新取一次当前时间戳不要缓存签名。如果只需要处理存量数据可以把timestamp设为一个固定值同时把sign也按那个固定时间计算好。但要注意这种做法只对不做新鲜度校验的接口有效。5.2 Cookie里的会话标识和风控策略有些接口除了sign参数之外还会在Cookie里种一个会话标识。这个标识可能是首次访问页面时由JS动态生成的。如果只带签名、不带这个Cookie服务器会判定当前请求不是来自可信浏览器环境。处理这个问题的通用方案是先用requests.Session()建立一个会话。先GET一次排行榜页面让服务器和JS生成Cookie。再携带会话Cookie去请求接口。session requests.Session() session.headers.update(headers) session.get(https://example.com/rank, timeout10) resp session.get(BASE_URL, paramsbuild_params(1, 20, desc), timeout10)另外要特别注意访问频率。排行类接口一般都有风控策略正常用户不会在几秒钟内翻几十页。如果为了拉全量数据而高频请求很快就会被封IP或者要求滑块验证。我的经验是加一个time.sleep(random.uniform(1, 3))的随机延时把单次请求的节奏控制在一个合理范围。5.3 请求头顺序和浏览器指纹的隐性校验在更进阶的场景里服务器甚至会把Headers的顺序、字体指纹、Canvas绘制结果等作为校验依据。不过pp网的排行榜接口目前没有走到这一步至少我测试时没有遇到。如果你的目标站点出现了比sign更复杂的校验不要慌排查顺序是先确认所有显式参数是否正确再确认请求头是否完整最后才考虑环境指纹问题。因为越往后的校验实现成本越高不是所有站点都愿意做的。5.4 关于JavaScript逆向的使用边界最后说一个偏“道”的层面。JS逆向这个技术本身是中性的做接口分析、调试前端逻辑、确认数据请求过程都是很正常的研发工作。但拿到签名算法之后具体用来做什么是每个人都应该想清楚的问题。我的原则是两条只对自己有权限的数据做分析。比如调试自己参与开发的项目、分析自己账号能看到的数据范围或者获得了明确授权的目标。控制请求量不以破坏性方式访问目标服务。排行数据通常有官方导出或合规的获取渠道能用正规方式拿到的尽量优先用正规方式。如果你的诉求只是“分析一个网页里某个加密参数是怎么生成的”完全可以把它当成训练JS逆向思路的练习题但如果要做大范围的自动化采集还是先确认目标网站的条款和合规边界避免给自己惹上不必要的麻烦。我在实际分析这类接口时还有一个体会比起一次性写出一个完美复现脚本更值得花时间的是把调试链路走通一遍。第一步怎么找接口、第二步怎么判断加密参数、第三步怎么断点定位函数、第四步怎么验证算法这四个步骤才是通用的能力。换一个网站、换一套签名算法只要你把排查链路记熟了剩下的就是时间问题。
返回列表