☰
Python逆向实战:MD5签名评论接口抓取与参数加密还原
2026/10/27 15:48:12 网站建设 项目流程

评论数据在内容平台的分析场景里一直是个绕不开的刚需,做选题分析、舆情观察、用户画像,甚至单纯想看看某条视频下面大家在聊什么,都绕不过去。但真正动手抓过的人都知道,评论接口往往不是裸奔的,参数里藏着一层签名或加密,尤其是表单提交类的接口,MD5 签名是最常见的一种。这次我拿一个内容平台的评论抓取场景做复盘,核心难点就卡在表单参数的 MD5 加密上——请求发出去要么返回参数错误,要么直接空数据,抓包看着参数都对,复制到代码里就是过不去。这篇内容适合有一定 Python 基础、抓过简单接口但被签名卡住的朋友,也适合想系统理解“前端加密参数怎么逆向”的读者。我会把定位加密点、还原算法、构造请求、稳定批量抓取这条链路完整走一遍,重点讲清楚每一步为什么这么做,以及我踩过的那些坑。

1. 整体思路与方案选型拆解

1.1 先搞清楚这类评论接口的请求结构

动手之前得先明白目标长什么样。内容平台的评论列表接口,绝大多数是 GET 或 POST 的表单/JSON 混合结构,分页参数(页码、游标、每页数量)、视频或内容 ID、排序方式这些是明面上的,真正拦人的是额外附加的签名字段。我这次遇到的接口是 POST 表单提交,Content-Type是application/x-www-form-urlencoded,参数里除了oid(内容 ID)、pn(页码)、ps(每页条数)、type之外,还有一个看着像随机串的w_rid和一个时间戳wts。这两个字段就是签名体系的核心,wts是当前秒级时间戳,w_rid则是把一堆参数按规则拼接后做 MD5 得到的摘要。

为什么先讲请求结构?因为逆向的第一步永远是“确认哪些参数是服务端校验的、哪些是客户端随便填的”。很多人一上来就盯着加密函数看,结果连哪些字段参与签名都没搞清,还原出来的算法自然对不上。我的做法是:抓一个成功请求,把参数逐个删掉重发,看哪个删了会报错。实测下来oid、pn、ps、type、wts、w_rid这六个是硬性的,少一个就返回签名错误;而像web_location、plat这类字段删了不影响结果,属于可选项。这一步花不了几分钟,但能帮你把签名输入的范围圈死。

1.2 为什么选“抓包定位 + 本地还原”而不是硬猜

面对 MD5 签名,常见的路子有三条:一是硬猜拼接规则,二是用自动化工具跑前端 JS,三是抓包定位加密函数后在本地用 Python 复现。第一条基本靠运气,参数一多组合爆炸,不现实;第二条工具链重,遇到混淆严重的 JS 还得处理运行环境,调试成本高;第三条是我一贯推荐的,因为 MD5 这类摘要算法本身没有密钥协商的复杂度,只要把“拼接顺序 + 盐值”搞对,本地几行代码就能复现,稳定性和可维护性都最好。

这里要解释一个关键判断:MD5 签名能不能本地还原,取决于盐值(salt)是不是硬编码在前端。如果盐值是服务端下发或者动态协商的,那本地还原就得额外处理;但绝大多数内容平台的 Web 端签名,盐值是写死在前端 JS 里的固定字符串,这就给了本地复现的空间。我这次的情况正是如此,盐值是一个固定的短字符串,拼接规则也是固定的“参数按 key 字典序排序后拼成 query 串,末尾接盐值,再整体 MD5”。搞清楚这个规则,后面就是纯工程活了。

1.3 方案的整体链路设计

把链路拆开看是四段:抓包确认参数 → 定位前端加密逻辑 → 本地复现签名函数 → 构造请求并验证。这四段是有严格先后顺序的,不能跳。抓包确认参数是地基,定位加密逻辑是核心,本地复现是落地,构造请求验证是闭环。我见过不少人定位到加密函数后直接抄逻辑,结果因为参数顺序或编码问题对不上,又回头重抓,来回折腾。

链路里还有一个容易被忽略的点:时间戳的同步。wts参与签名,服务端会校验它和服务器时间的偏差,偏差过大直接拒绝。所以本地复现签名时,时间戳必须用请求发起那一刻的真实时间,不能缓存、不能写死。这一点在批量抓取时尤其要注意,后面实操部分会细讲。

2. 核心细节解析与实操要点

2.1 抓包与参数范围的圈定

抓包工具我用的是浏览器开发者工具的网络面板,够用且不用额外装东西。打开目标内容页,切到评论区域,触发一次评论加载,在网络面板里筛Fetch/XHR,找到返回评论 JSON 的那个请求。点开看请求详情,重点看三处:请求方法、Content-Type、表单参数列表。把参数原样复制下来,包括那个看着像乱码的w_rid。

圈定参数范围的操作我前面提过,这里给个更细的做法:准备一个能改请求的工具(浏览器里可以用重放功能,或者用脚本发),每次删一个参数重发,记录返回码。我实测的结论整理成下面这张表,方便你对照自己的场景:

参数名作用是否参与签名删除后表现
oid内容唯一 ID是返回参数缺失错误
pn页码是返回参数缺失错误
ps每页条数是返回参数缺失错误
type内容类型标识是返回参数缺失错误
wts秒级时间戳是签名校验失败
w_ridMD5 摘要是(结果本身)签名校验失败
web_location页面位置标识否无影响
plat平台标识否无影响

这张表的价值在于,它直接告诉你签名函数的输入集合。注意w_rid本身不参与“被签名”,它是签名结果;真正参与拼接的是除它之外的五个必填参数加盐值。很多人把w_rid也塞进拼接串里,那必然对不上,这是个高频错误。

提示:删参数测试时,建议用同一个wts和w_rid组合反复试,避免时间戳变化干扰判断。如果删了某参数返回的是“签名错误”而不是“参数缺失”,说明它参与了签名但服务端有默认值兜底,这种情况要单独记下来。

2.2 定位前端加密逻辑的关键手法

参数范围圈定后,下一步是找到前端在哪里算的w_rid。手法上我常用两招配合:全局搜索关键词和断点调试。先在开发者工具的 Sources 面板里全局搜w_rid,一般能搜到赋值的地方;如果搜不到(被混淆了),就搜MD5、md5、hex_md5这类算法特征词,或者搜盐值那个固定字符串的片段。

搜到之后不要急着读代码,先下断点。在疑似计算w_rid的那行下断点,重新触发一次评论加载,代码会停在那里,这时候看调用栈和局部变量,能直接看到拼接前的原始字符串长什么样。这一步是整个逆向里最爽的环节——你不需要读懂整段混淆代码,只需要在断点处把拼接好的明文串捞出来,然后拿它和最终w_rid做一次本地 MD5 验证,对上了就说明规则找对了。

我这次的实际过程是:搜w_rid搜到一行类似w_rid: md5(querystring + salt)的赋值,下断点后看到querystring是参数按字典序拼成的key=value&key=value形式,salt是一个固定短串。把断点处的明文串复制出来,本地hashlib.md5(明文.encode()).hexdigest()一跑,和抓包里的w_rid完全一致,规则确认。整个过程不到二十分钟,比啃混淆代码高效得多。

注意:断点捞明文时,务必确认参数值的编码方式。表单提交里中文和特殊字符会被 URL 编码,拼接时用的是编码后的值还是原始值,直接影响结果。我这次用的是编码后的值,也就是quote处理过的形式,这一点在复现时不能想当然。

2.3 盐值与拼接规则的确认方法

盐值的确认有个笨但可靠的办法:在断点处把拼接串和盐值分别打印出来,然后本地做“去掉盐值再 MD5”和“加上盐值再 MD5”的对比,哪个和抓包一致就是哪个。拼接规则同理,重点确认三件事:排序方式(字典序还是固定顺序)、连接符(&还是空串)、末尾是否带连接符。这三件事任意一个错,MD5 结果就完全不同。

我这次的规则是:参数按 key 的字典序升序排列,用&连接成k1=v1&k2=v2形式,末尾不加&,然后直接接盐值字符串,整体做 MD5 取小写十六进制。这里有个细节值得说:字典序排序时,oid、pn、ps、type、wts的字母顺序是oid < pn < ps < type < wts,注意pn和ps的顺序,pn在前。这种细节靠肉眼容易看错,建议在代码里用sorted()让程序排,别手写顺序。

2.4 编码与字符集的两个坑

第一个坑是URL 编码的时机。表单提交时,参数值里的中文、空格、特殊符号会被编码,但签名拼接用的是编码前还是编码后的值,不同平台不一样。我这次实测是编码后的值参与拼接,也就是说你得先对每个 value 做quote,再拼进签名串。如果搞反了,英文数字参数没事,一旦遇到中文评论内容或带空格的参数就全错。

第二个坑是字符集。MD5 是对字节做摘要,字符串转字节时的编码方式必须和服务端一致。前端 JS 里字符串是 UTF-16,但做 MD5 前一般会转成 UTF-8 字节,所以 Python 侧统一用encode('utf-8')即可。我见过有人用encode('gbk')导致中文参数签名对不上,排查半天以为是拼接规则错了,其实是编码问题。

提示:验证签名是否正确,最直接的办法是拿抓包里的原始参数和w_rid做一次离线复算,一致了再发真实请求。离线复算不消耗请求次数,也不触发风控,是调试期最安全的验证手段。

3. 实操过程与核心环节实现

3.1 环境准备与依赖说明

环境上没什么特殊要求,Python 3.8 以上即可,依赖就三个:requests发请求、hashlib做 MD5(标准库自带)、urllib.parse做 URL 编码(标准库自带)。不需要装额外的加密库,MD5 在hashlib里现成的。如果你习惯用httpx也行,接口调用方式差不多,本文以requests为例。

pip install requests

装完确认一下版本,requests建议 2.28 以上,老版本在某些 TLS 场景下会有兼容问题。这一步没什么技术含量,但环境不对后面全是玄学错误,值得花一分钟确认。

3.2 签名函数的完整实现

签名函数是整个方案的心脏,我把它单独抽成一个函数,输入是参数字典,输出是拼好的表单数据(含wts和w_rid)。这样设计的好处是签名逻辑和请求逻辑解耦,调试时单独测签名函数就行,不用每次都发网络请求。

import hashlib import time from urllib.parse import quote, urlencode SALT = "固定盐值字符串" # 从前端断点处确认的实际盐值 def build_signed_params(base_params: dict) -> dict: params = dict(base_params) params["wts"] = int(time.time()) # 按 key 字典序排序 sorted_keys = sorted(params.keys()) # 拼接:key=value 形式,value 做 URL 编码 parts = [] for k in sorted_keys: v = str(params[k]) parts.append(f"{k}={quote(v, safe='')}") raw = "&".join(parts) + SALT w_rid = hashlib.md5(raw.encode("utf-8")).hexdigest() params["w_rid"] = w_rid return params

这段代码有几个点要展开说。quote(v, safe='')里的safe=''表示连/都编码,这是为了和前端encodeURIComponent的行为对齐——前端表单序列化时对特殊字符的处理比较激进,safe留空最保险。wts用int(time.time())取秒级时间戳,注意是秒不是毫秒,毫秒级时间戳参与签名会直接对不上。拼接时raw末尾直接接SALT,中间没有额外分隔符,这个细节必须和断点处看到的完全一致。

签名算完后把w_rid塞回参数字典,注意此时字典里已经有wts了,发请求时用urlencode或直接作为data传给requests都行。这里有个小陷阱:requests的data参数传字典时会自动做 URL 编码,但编码规则和quote略有差异(比如空格编成+还是%20),为了和签名时的编码保持一致,我建议签名时怎么编码,发请求时就怎么编码,最稳的做法是手动urlencode成字符串再传。

3.3 构造请求与首次验证

请求构造这块,headers 要带全,尤其是User-Agent、Referer、Content-Type。Referer建议填目标内容页的地址,有些平台会校验来源;Content-Type必须是application/x-www-form-urlencoded,因为签名体系就是按表单设计的。下面是我用的请求骨架:

import requests def fetch_comments(oid: int, page: int = 1, page_size: int = 20): url = "https://目标域名/评论接口路径" base_params = { "oid": oid, "pn": page, "ps": page_size, "type": 1, } signed = build_signed_params(base_params) body = urlencode(signed) headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...", "Referer": f"https://目标域名/内容页/{oid}", "Content-Type": "application/x-www-form-urlencoded", } resp = requests.post(url, data=body, headers=headers, timeout=10) return resp.json()

首次验证时,我建议先只抓第一页,把返回的 JSON 打印出来看结构。正常返回里会有评论列表、总数、是否还有下一页这些字段。如果返回的是错误码,先别急着改代码,拿这次请求的参数和抓包里的成功请求逐字段对比,重点看wts和w_rid是否对得上——注意wts每次请求都会变,所以w_rid也必然不同,不能直接比字符串,要比的是“用同样规则算出来的结果是否被服务端接受”。

3.4 分页抓取与稳定性处理

单页通了之后就是分页。分页的核心是搞清楚翻页机制:是按页码递增,还是按游标(cursor)翻。我这次是页码递增,pn从 1 开始,每页 20 条,抓到返回列表为空或pn超过总页数就停。这里要注意,每翻一页都要重新算签名,因为wts变了,w_rid必须跟着变,不能复用上一页的签名。

稳定性上我做了三件事。第一是请求间隔,每页之间 sleep 0.5 到 1 秒,别小看这个,连续快速请求很容易触发频率限制,返回空数据或错误码。第二是重试机制,对超时和 5xx 错误做最多三次重试,重试时重新生成签名(因为时间戳变了)。第三是异常兜底,JSON 解析失败或字段缺失时记录原始响应,方便事后排查,而不是直接崩掉。

import time def crawl_all(oid: int, max_pages: int = 50): all_comments = [] for pn in range(1, max_pages + 1): try: data = fetch_comments(oid, page=pn) except Exception as e: print(f"第 {pn} 页请求异常: {e}") time.sleep(2) continue comments = data.get("data", {}).get("replies") or [] if not comments: break all_comments.extend(comments) print(f"第 {pn} 页抓到 {len(comments)} 条,累计 {len(all_comments)} 条") time.sleep(0.8) return all_comments

这段代码里max_pages是个保险丝,防止翻页逻辑出错时无限循环。实际抓取时,如果某页返回空但总数显示还有,可能是该页被风控拦截了,这时候 sleep 久一点再重试,别硬刚。

3.5 数据落盘与字段整理

抓到数据后建议立刻落盘,别全攒内存里。我习惯按页存 JSON,文件名带页码,方便断点续抓。字段上评论数据一般有用户 ID、昵称、评论内容、点赞数、发布时间、回复数这些,按需取用即可。落盘时注意编码用 UTF-8,ensure_ascii=False,不然中文全变转义字符,后面分析还得再解一次。

import json def save_page(pn: int, comments: list): with open(f"comments_page_{pn}.json", "w", encoding="utf-8") as f: json.dump(comments, f, ensure_ascii=False, indent=2)

落盘这个动作看着琐碎,但批量抓取时价值很大——中途断了不用从头来,直接从最后一页的下一页续上就行。我一般还会额外存一个进度文件,记录已完成的页码,续抓时读它。

4. 常见问题与排查技巧实录

4.1 签名对不上的排查顺序

签名错误是这类逆向里最高频的问题,我总结了一个固定的排查顺序,按这个顺序走基本能定位到根因。第一步,确认参与签名的参数集合,是不是多塞了w_rid或者漏了某个必填项;第二步,确认排序规则,用代码sorted()排,别信手写顺序;第三步,确认编码方式,value 是否做了 URL 编码、编码用的safe参数是否和前端一致;第四步,确认盐值和拼接格式,末尾有没有多余分隔符、盐值前后有没有空格;第五步,确认时间戳单位,秒还是毫秒。

这五步里,我踩得最多的是第三步和第五步。编码问题在纯英文参数下完全看不出来,一旦参数里有中文或特殊符号就暴露;时间戳单位错了则表现为“偶尔成功偶尔失败”,因为秒和毫秒的数值差异在某些边界上可能碰巧对上,极具迷惑性。

现象可能原因排查动作
一直返回签名错误参数集合不对/排序错对照断点明文逐字符比对
英文参数正常,中文参数失败URL 编码不一致检查 quote 的 safe 参数
偶尔成功偶尔失败时间戳单位错/时钟偏差确认秒级时间戳,校准本地时间
返回空数据但无错误码触发频率限制加大请求间隔,降低并发
第一页正常,后续页失败签名复用/游标错误每页重新签名,确认翻页机制

4.2 断点调试时的常见卡点

断点调试有两个高频卡点。一是断点打不上,代码被混淆或异步加载,断点位置不对。解决办法是在网络请求发起的位置下 XHR 断点,或者搜send、fetch这类请求方法名,从请求发起处往回找签名计算的位置。二是断点处变量是压缩后的短名,a、b、c看不出含义,这时候别硬读,直接把变量的值打印出来,用值反推含义——比如某个变量值是拼接好的 query 串,那它就是签名输入。

还有一个细节:断点触发时页面可能卡住,如果断点下在评论加载的必经路径上,页面会一直转圈。这时候别慌,看完变量值直接放行或取消断点即可。我习惯在断点处把关键变量复制到记事本,再放行,避免反复触发。

4.3 批量抓取的风控规避经验

批量抓取最怕的不是签名,是风控。签名对了但请求太密,照样给你返回空数据或错误码。我的经验是:控制频率、模拟真实、分散请求。频率上单 IP 每秒不超过一次请求比较稳妥,间隔加随机抖动(0.5 到 1.5 秒随机)比固定间隔更像真人;模拟真实上 headers 带全,Referer别省,User-Agent用常见浏览器的;分散请求上如果量大,分时段抓,别一口气抓几千页。

另外,返回空数据时不要立刻重试,先 sleep 几秒再试一次,连续两次空就停一段时间。硬刚的结果往往是临时限制升级,得不偿失。我一般会在抓取脚本里加一个“连续空页计数”,连续三页空就自动停,人工介入检查。

4.4 签名规则变更的应对

前端签名规则不是一成不变的,平台改版时盐值或拼接规则可能变,表现为原本能跑的脚本突然全返回签名错误。这时候别怀疑自己的代码,先重新抓包看w_rid的格式有没有变(长度、字符集),再重新走一遍断点定位流程。我的做法是把盐值和拼接规则抽成配置项,改版时只改配置不改逻辑,切换成本低。

还有一种情况是平台加了新的必填参数,比如多了一个platform字段参与签名。这种变更通过“删参数测试”能快速发现——原本可选的参数突然变成必填,删了会报签名错误。所以定期重跑一次参数范围测试是个好习惯,能提前发现规则变更。

5. 逆向思路的可迁移性

这套“抓包圈参数 → 断点捞明文 → 本地复现 → 请求验证”的流程,不只适用于 MD5 表单签名。换成 SHA 系列、换成 HMAC、甚至换成简单的参数拼接校验,思路是一样的:核心永远是找到签名输入的明文和变换规则,MD5 只是变换规则里最简单的一种。我后来遇到过一个用 SHA256 加时间窗口的接口,流程完全没变,只是把hashlib.md5换成hashlib.sha256,盐值拼接方式微调,半小时就通了。

真正需要额外投入的是遇到非对称加密或动态密钥的场景,那时候本地复现就不够了,得考虑在可控环境里执行前端逻辑。但就内容平台评论接口这个场景而言,MD5 表单签名占了大多数,把这套流程吃透,覆盖面已经很广。我个人在实际操作中的体会是,逆向的难点从来不在算法本身,而在确认输入——参数集合、排序、编码这三件事确认清楚,剩下的都是几行代码的事。踩过的坑大多也集中在这三件事上,而不是 MD5 算不出来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询