JS逆向实战:从403到200,破解接口加盐MD5签名参数
发布时间:2026/9/18 2:33:07来源:尧图网络
1. 为什么一个参数能让脚本直接翻车1.1 从浏览器到代码我卡在了哪一步先说下这个项目的起因。当时接了个需求要抓某球网的比赛数据做实时赛事信息展示。这个站在PC端的页面结构不算复杂接口路径也比较规整按常规思路应该是个半小时就能搞定的活儿。结果我写好Python脚本模拟请求的时候接口直接给我甩了个403 Forbidden。反复检查了Headers、Cookie、Referer该补的全补了还是被拒。最后用浏览器开发者工具翻了一下请求参数发现所有接口的Query里都带着一个很扎眼的参数md5__1038。而且这个参数的值在我复制出来的请求里已经自动带上了看起来是一个32位的十六进制字符串。问题就有意思了这个参数是前端JS动态生成的不是服务端直接下发到页面的。也就是说请求体里少了这个加密参数服务器根本不认你。这不是简单的UA校验问题而是典型的接口签名防爬策略。那剩下的事情就明确了必须搞清楚这个md5__1038是怎么算出来的。1.2 先别急着读JS把接口结构摸清楚很多新手一上来就打开JS文件一顿搜搜完了也不知道自己在看什么。我的习惯是先把接口的完整形态摸清楚再决定怎么切入。拿到请求后我先把完整的请求信息录下来包含几个关键点请求URL全路径包括所有Query参数除了md5__1038以外的其他参数有哪些以及它们的值在每次请求中是否变化当前页面的完整URL请求顺序页面加载时先请求了哪些接口Cookie请求头尤其是服务端种下的动态Cookie是否参与后续签名生成当时录到的典型请求长这样GET /api/match/live?matchId1001234_t1698739200000md5__10383ab8e4f2c76a1d9b5f0e6c2d8e4f6a1b HTTP/1.1 Host: www.xxxterrace.com User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ... Cookie: xxx_cookieabc123; yyy_sessiondef456这里有个非常值得注意的细节_t这个参数明显是时间戳13位毫秒级而md5__1038跟在它后面。数据链路是先加载HTML和JS然后由JS动态计算出这个加密值拼到请求URL里再发出去。在动手读JS之前我还干了一件比较笨但很有效的事手动改_t的值再刷新页面。结果发现只要_t变化md5__1038的值也会变。说明_t大概率参与了签名计算。这一步虽然不产生直接结果但能把可疑变量的范围缩小一大截后面追踪的时候省很多事。2. 定位加密函数从全局搜索到断点还原2.1 全局搜索 md5__ 前缀先找到赋值点打开Chrome开发者工具的Sources面板切到需要调试的页面然后按CtrlShiftF进行全局搜索搜md5__或者md5__1038。这一步的目的是找到这个参数名在哪些JS文件里出现过。实际的搜索结果是一个编号为sports_xxx_1.0.123.js的压缩文件里出现了md5__1038的赋值语句。因为是压缩混淆过的代码整行长这样var o { md5__1038: (n.hash || ) }看到这行代码的时候我第一反应是蒙的n.hash是什么难道是location.hash但页面URL里明显没有#号。于是我得继续往下找n这个对象从哪来。这里有一个经验要分享全局搜索的时候不要只搜完整的md5__1038有时候混淆代码里会把字符串拼接成变量名。比如用md5__ timestamp这种方式动态构造你搜完整词根本搜不到。正确做法是分两步先搜md5__再搜1038或者直接搜md5__配合周围执行上下文判断。我这次运气好搜md5__就有结果了。2.2 断点命中后顺着调用栈往回追在赋值语句那一行打上断点然后刷新页面。断点如期命中此时我在Watch面板里看n的完整结构发现它是一个配置对象里面存了一堆参数包括当前接口的路径、方法名、请求头信息等。n.hash其实是另一个函数返回的结果只不过被压缩工具改成了短变量名。既然md5__1038的值来源于n.hash那继续往下追n的定义就能找到hash的计算函数。我这边的做法是鼠标悬停在n.hash上右键选择Jump to definition跳到了另一个函数function t(n) { return u.md5(p.salt n p.salt) }看到这行核心逻辑已经浮出水面把固定字符串p.salt、某个值n、再拼接p.salt整体做一次MD5。这是一种典型的防爆破签名设计俗称“加盐MD5”。攻击者就算逆向出加密方式是MD5拿不到盐值照样造不出合法请求。但还有个关键问题没解决n是什么。继续往上追发现调用这个函数的上层代码把当前页面的_t参数值传了进去也就是13位毫秒级时间戳。到这里加密链路已经通了一半md5__1038 MD5(salt _t salt)。2.3 混淆代码里的常见干扰项都说逆向难其实难的不是算法本身而是怎么在压缩混淆的代码里找到真正有用的那条执行路径。这类代码里我见过太多干扰项这里举几个典型的大量无实际作用的死代码分支if(false)包裹的函数体专门给自动分析工具和新人挖坑变量名复用同一个变量名在不同作用域里指向完全不同的对象字符串拼接混淆所有函数名、参数名都拆成两到三段运行时再拼起来控制流平坦化把原本顺序的逻辑改成whileswitch的循环结构看代码的难度直接翻三倍遇到这种情况我的建议是别硬看代码靠断点调试配合控制台输出观察函数输入输出。能用工具解决的事情就不要用眼睛硬干。我常用的手段是直接在Console里调用目标函数传入不同的时间戳观察输出变化从而验证我的推测。比如// 在Console里手动调用可疑函数 t(1698739200000) // 输出: 3ab8e4f2c76a1d9b5f0e6c2d8e4f6a1b接着用Python本地算一下同样的MD5看结果是否一致。如果不一致说明还有别的变量参与如果一致加密逻辑基本就锁定住了。3. 加密逻辑还原与Python复现3.1 核心逻辑完整拆解通过断点加控制台验证最终确认的完整生成流程如下页面从服务端获取一份配置文件有时是请求某个接口下发的里面包含salt字段这个字段在每次会话中可能不同页面加载后读取当前_t时间戳执行MD5(salt _t salt)得到32位字符串将结果赋给md5__1038拼到请求Query里发送注意md5__1038里的数字1038并不是固定的。我看了一眼不同版本的JS文件里这个后缀不一样有的是1038有的是1056还有的是984。这个数字更像是一个内部版本号或者生成时间相关的随机标记用来防止爬虫通过精准匹配参数名来绕过。所以在写脚本时不要硬编码参数名。最稳妥的做法是先请求页面拿到当前JS文件内容用正则把md5__\d提取出来再动态替换。这样就算网站升级改了这个数字你的脚本也不容易挂。3.2 requests落地实现下面是我最终采用的Python实现。整体流程分三步会话初始化、获取配置、请求带签名接口。import re import hashlib import time import requests session requests.Session() # 1. 模拟浏览器访问首页拿到Cookie和JS文件路径 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, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Referer: https://www.xxxterrace.com/, } home_resp session.get(https://www.xxxterrace.com/, headersheaders) print(首页状态码:, home_resp.status_code) # 2. 从首页HTML里提取JS文件路径常见的script标签提取 script_urls re.findall(rscript[^]src([^]\.js), home_resp.text) if not script_urls: raise Exception(未找到JS文件路径) # 找出包含md5__关键词的那个JS文件 suffix None for js_url in script_urls: if not js_url.startswith(http): # 补全为绝对地址 js_url https://www.xxxterrace.com js_url js_resp session.get(js_url, headersheaders) # 用正则提取md5__后面的数字部分 m re.search(rmd5__(\d), js_resp.text) if m: suffix m.group(1) break if not suffix: raise Exception(未找到md5__参数名) param_name fmd5__{suffix} print(动态参数名:, param_name) # 3. 从JS文件里提取salt值。我这里的实际提取方式是根据特征字符串从代码里抠出来 salt_match re.search(rsalt\s*[:]\s*([0-9a-f]{16}), js_resp.text) if salt_match: salt salt_match.group(1) else: # 部分版本是单独接口下发需要额外请求配置接口 config_resp session.get(https://www.xxxterrace.com/api/config, headersheaders) salt config_resp.json()[data][salt] print(salt:, salt) # 4. 构造请求参数 def make_sign(ts: str) - str: raw salt ts salt return hashlib.md5(raw.encode(utf-8)).hexdigest() def build_params(match_id: int) - dict: ts str(int(time.time() * 1000)) return { matchId: match_id, _t: ts, param_name: make_sign(ts), } # 5. 发起真实请求 api_url https://www.xxxterrace.com/api/match/live params build_params(1001234) api_resp session.get(api_url, paramsparams, headersheaders) print(接口状态码:, api_resp.status_code) print(接口响应:, api_resp.text[:500])这段代码跑通之后请求状态码直接从403变成了200接口正常返回比赛数据。3.3 为什么MD5结果是32位小写而不是大写写Python复现的时候有个小细节就是hashlib.md5的结果默认是32位小写十六进制。而我抓包的时候看到的md5__1038的值恰好也是小写。如果你抓到的值是全大写那就需要多做一步.upper()。这个匹配逻辑看似简单但在实际项目中经常被忽略。不同网站同一个算法可能输出不同大小写或者加了额外的编码转换所以我在写脚本的时候习惯先打印一次解析结果和浏览器里的值比对一下再批量跑。另外一个常见坑是UTF-8编码问题。JS里的字符串加密默认UTF-8但Python的hashlib.md5如果直接传字符串会报错必须先.encode(utf-8)。如果JS里用了encodeURIComponent那Python这边也要相应处理URL编码。幸好这个站比较简单没有走那层。4. 实测中的几个坑每一个都能让你白忙一场4.1 时间戳精度问题13位和10位的坑刚开始复现的时候我犯过一个很低级的错误time.time()默认返回10位秒级时间戳我直接拼进字符串算MD5结果怎么都对不上。后来仔细对比发现请求里_t是13位毫秒级必须乘以1000再截断小数点。# 错误写法 ts str(int(time.time())) # 正确写法 ts str(int(time.time() * 1000))这个错虽然低级但如果不仔细比对能卡你一下午。我的排查方法是用一个固定的时间戳同时塞进浏览器Console和Python脚本里算出的MD5如果一致说明时间戳单位没问题否则就是精度不对。4.2 salt不是固定的版本更新之后就变了这个站有意思的点在于salt字段并不是写死在JS文件里。我抓包对比了两次会话发现每次启动页面加载时都会先从/api/config接口拿到最新的salt然后才去拼接生成md5__1038。也就是说你就算把这次拿到的salt硬编码进Python脚本脚本顶多跑一两天就会失效。因为服务端会定期更新salt或者每次会话单独生成并下发。所以正确做法是脚本里保留“每次请求前先拉取config解析最新salt”的逻辑。我上面的代码里已经包含了从JS文件正则匹配salt的逻辑这里再强调一下_t加盐这个关系本身不难难的是维持长期可用。如果服务器每次会话都动态下发不同salt你还得额外维护一个会话状态让Cookie和salt绑定。4.3 并发请求下的参数一致性问题项目跑到后期我拿这个接口做了并发抓取结果踩了一个非常隐蔽的坑多线程同时发请求时每个线程各自生成自己的_t和md5__1038这本身没问题。问题出在某个批量场景里我用同一个_t给不同请求签名忘记了md5__1038是基于_t算出来的。结果就是服务端拿到的请求带着一个和请求时间明显不匹配的_t直接被识别为异常请求触发风控所有请求都返回403。我的解决方式很简单在并发请求里把签名函数做成线程安全的独立工具类每次生成请求参数时都强制刷新时间戳。import threading lock threading.Lock() def build_params_with_lock(match_id: int) - dict: with lock: ts str(int(time.time() * 1000)) return { matchId: match_id, _t: ts, param_name: make_sign(ts), }这个改动看似不起眼但直接决定了整个并发抓取链路能不能稳定跑下去。权限验证这块网站反爬检测一般会在固定时间窗口内分析流量特征如果请求签名值都是同一个时间戳算出来的哪怕其他特征都正常被识别也只是时间问题。4.4 浏览器调试时的Object.defineProperty陷阱还有一个细节值得单独拿出来说。当时我在Console里验证salt取值时直接用了一个全局变量名结果怎么赋值都不对因为页面的混淆代码里用Object.defineProperty对全局作用域做了拦截我声明变量会触发重写。这让我花了十几分钟才意识到问题不在算法而是调试环境被污染了。后来我改用window.tmpSalt作为自定义变量名避开混淆代码保留的命名空间调试才恢复正常。如果你也遇到类似问题Console里无论如何都算不出正确结果可以先看看页面的混淆代码是否对window对象做了大量自定义属性注入。这类站点的反调试手段会刻意保留一批变量名一旦你撞上行为会很诡异。5. 这类逆向项目的通用方法论这次逆向md5__1038算是一个典型的“加盐MD5”入门案例。如果你接下来打算继续做JS逆向有几个方法论层面的经验我认为比具体的加密算法更值钱第一先抓包再读代码。我先通过抓包确认参数存在、参数变化、参数参与度才决定下一步。如果你连参数是否每次都变、变化和什么相关都不知道直接打开JS文件硬啃大概率会被混淆代码绕晕。第二断点永远比肉眼好使。在关键赋值语句上打断点观察调用栈和变量值比逐行读压缩代码要快得多。Chrome DevTools的Step Into和Call Stack面板就是逆向的核心工具。这个习惯我在做更复杂的站时也一直用比如涉及多个加密函数嵌套调用时只靠静态分析几乎不可能还原准确。第三拿到算法后一定要做“输入-输出”验证。用固定输入测试输出确保Python端复现结果和浏览器端一致。只要你输出的MD5和浏览器里的值对不上后面的请求必然失败。多花几分钟做一次单元验证能省出几个小时的排错时间。第四参数名不要硬编码。这次是md5__1038下次可能变成md5__1042或者换成md5x_xxx前缀。我在脚本里用正则动态提取参数名就是为了让程序在网站升级后依然能自动适配。爬虫对抗本质是动态博弈你写死任何一个变量都是在给自己埋定时炸弹。第五风控意识要贯穿始终。就算你完全还原了签名逻辑请求频率一高还是会被服务端发现。合理设置抓取间隔、避免高峰期集中请求、必要时用代理IP分散来源这些基础工作反而决定了你的脚本能活多久。签名只是敲门砖行为特征才是最核心的存活指标。这个项目后期我还对配置接口增加了一层缓存本地把拿到的salt存起来30分钟内不再重复请求配置接口减少对目标站的探测频次同时也降低被风控盯上的概率。这个方案跑了两周没出问题算是一个比较可靠的组合拳。
网站建设高端定制企业官网