1. 写在前面:为什么会碰这个参数
搞过闲鱼H5逆向的朋友应该都有印象,抓包时请求头里总跟着一个h5sign参数,一串类似MD5的32位字符,不按常规出牌。我最初以为就是个简单的签名,把参数丢进MD5就完事了,结果折腾下来发现,这里面藏的弯弯绕比想象中多。
这篇日记记录的是我从零开始还原h5sign生成逻辑的完整过程,包含抓包定位、JS断点调试、加密函数追踪、动态参数比对、以及最终的自动化脚本落地。适合对Web逆向有一定基础、但对闲鱼H5签名机制还比较陌生的朋友参考。
先说结论:h5sign本质上是一个与请求参数、时间戳、Cookie中的关键值强绑定的签名串,生成逻辑藏在首页加载的某个webpack模块里,需要从调用栈反推入口,再逐层剥离混淆才能看到全貌。整个过程中最坑的其实不是加密算法本身,而是参数来源的追踪——如果你不知道h5sign里到底掺了哪些原料,就算拿着算法代码也没法稳定复现。
接下来我把整个还原过程按时间线拆开,每一步都标注了当时的思路和踩坑记录,希望能帮你少走点弯路。
2. 第一步:用抓包锁定h5sign的生成场景
2.1 从Fiddler到Charles的切换
我刚开始用的是Fiddler,但闲鱼H5的流量走的是HTTPS,Fiddler装证书后还是有一批请求解不开,后来换成Charles才好一点。这里给新手一个建议:抓H5包优先用Charles,证书装好后记得在手机上信任描述文件,同时打开SSL Proxying并添加goofish.com域名,否则你会看到一堆加密乱码,根本没法分析。
抓包后我主要观察两类接口:
- 商品详情页的
item.htm相关的异步加载接口 - 用户操作行为的上报接口,比如说点击、滑动、曝光这些
这两类接口的请求头里都带h5sign,同一个页面、同一个用户,请求不同接口时h5sign的值不一样。这说明它不是固定的,必然跟请求本身有关。
2.2 先看特征:h5sign它到底长什么样
我截取了几组数据对比,比如访问同一个商品页时:
请求A: GET /gw/mtop.taobao.detail.getdetail/6.0/?itemId=123456 HTTP/1.1 h5sign: 9f2c8b1d3a6e4f7b8c0d2e5a1f3b4c6d cookie: x5sec=abc123; cna=xyz789请求B: GET /gw/mtop.taobao.detail.getdetail/6.0/?itemId=654321 HTTP/1.1 h5sign: 2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d cookie: x5sec=abc123; cna=xyz789同样cookie,不同itemId,h5sign完全不同。我当时第一反应就是:h5sign至少跟请求参数有关,大概率是把参数序列化后做了某种摘要。
但这只是最外层的猜测。因为Cookie里的x5sec是风控下发的令牌,一般签名算法里也会掺这种动态值进去,具体怎么掺,还需要从JS里找答案。
2.3 把搜索范围缩到最小
在动手逆向之前,我先用文件搜索的思路过滤了一遍。闲鱼的H5包一般由多个JS文件组成,全部下下来在本地搜h5sign是能搜到的,但在线调试更直接——在Chrome DevTools的Sources面板里按Ctrl+Shift+F全局搜索h5sign,能看到它在那些JS里出现过。
我实际搜索的结果是:h5sign关键字出现的位置主要集中在两个文件里:
- app.js(webpack打包后的入口文件,体积最大)
- vendor.js(公共库文件,里面找得到一些通用工具函数)
搜索定位只是第一步,当时的直觉是:h5sign很可能不是直接用明文拼出来的,而是调用了某个公共函数。这个函数在vendor.js里被定义,在app.js里被调用,所以两边都能搜到名字。接下来的重点就是找到这个函数的具体实现。
3. 断点调试:从调用栈倒推加密入口
3.1 在XHR断点处拦住它
我习惯的做法是:在DevTools的Sources面板里,给所有XHR请求打上断点。操作方式是切到XHR/fetch Breakpoints,点加号,输入*表示拦截所有请求。这样页面一发起请求就会断下来,然后我可以在调用栈里看到是谁调用了这个请求、请求参数是怎么组装的。
断下之后,右侧Call Stack面板会列出从触发事件到发起请求的完整函数调用链。我逐个点进去看作用域(Scope)里的变量,特别关注哪一层作用域里出现了h5sign相关的字符串,或者哪一层在调用一个能生成签名的函数。
3.2 跟了三层调用栈之后的发现
我实际跟下来,调用栈大致长这样:
sendRequest (app.js:1200) -> fetchWithSign (app.js:1150) -> generateH5Sign (vendor.js:876) -> md5 (vendor.js:320)看到md5那一步时我心里大概有底了:h5sign就是一个MD5摘要,关键是它摘要的内容是什么。于是在generateH5Sign这一行打断点,重新触发请求,看函数的入参和局部变量。
断住后我展开Scope面板,看到了类似这样的变量:
{ token: "a3b4c5d6e7f8...", // 看起来是cookie里x5sec的一截 timestamp: "1688123456789", data: {"itemId":"123456","userId":"888888"}, secret: "f3a2b1c0..." // 这个值第一次见,估计是固定key }3.3 把断在源头:直接看generateH5Sign的内部
为了看清楚生成过程,我在generateH5Sign函数体内部第一个可执行语句上打断点,单步执行(F11),一行一行看它到底做了什么。
实际还原出来的伪代码大致是这个逻辑:
function generateH5Sign(options) { var token = getCookieToken(); // 从cookie里取出x5sec的一部分 var timestamp = String(Date.now()); var baseStr = token + "&" + timestamp + "&" + JSON.stringify(options.data); var sign = md5(baseStr + secretKey); return sign; }这里有几个关键信息:
token不是整个cookie值,而是截取其中一段,比如按_分割后取第一个片段- 时间戳直接参与了拼接,所以同一个请求在不同时间发出去,签名一定不同
secretKey是一个固定字符串,藏在vendor.js的某个常量池里
我花了很久在这个secretKey上,因为它不是明文写在函数旁边的,而是从配置对象里读出来的。需要先在代码里搜索类似appKey、secret、signKey的关键字,再到对应的配置文件里揪出它的值。
4. 参数还原:从固定字符串到动态变量的逐个击破
4.1 先把token的截取规则搞清楚
token这部分我开始理解错了。我以为是完整的cookie值,直接拿来拼进去,结果算出来的MD5和服务端的对不上。后来仔细看代码,发现它是这样处理的:
function getCookieToken() { var cookieStr = document.cookie; var match = cookieStr.match(/x5sec=([^;]+)/); if (match) { return match[1].split("_")[0]; } return ""; }也就是说,它从x5sec这个cookie里取出值,比如abc123_def456_ghi789,只取第一个下划线之前的abc123作为token参与签名。这个规则不追到函数内部根本猜不到——你传给MD5的到底是完整cookie还是片段,差一点结果就完全不一样。
我当时踩的坑是:直接用完整cookie去算,怎么算都跟抓包里的h5sign对不上,排查了很久才发现问题出在截取规则上。建议调试的时候,把断点打在这一层,把参与拼接的每个片段都提取出来,逐段拼,逐段比对,效率最高。
4.2 时间戳到底是毫秒还是秒
这个坑也很有代表性。生成h5sign时用的时间戳,我第一次以为是秒级,算出来差很多。后来在断点里看到Date.now()的返回值是13位的毫秒时间戳,才知道必须用毫秒。
但服务端校验时会不会做容错?我实测下来,如果用秒级时间戳去生成h5sign,请求会被拒绝,返回类似“签名错误”的信息。所以在实际脚本里,时间戳必须用毫秒,且前后误差不能太大,不然就算算法完全对,也会因为时间窗口问题被拒。
4.3 参数序列化的顺序和格式
h5sign的输入里包含请求参数,那参数的序列化方式就很重要了。我实测了一下,它用的是JSON.stringify而不是常见的key=value拼接。如果你是直接把参数对象转成查询字符串再签名,那结果必然不对。
还要特别注意对象属性顺序。JavaScript的对象属性在序列化时默认按插入顺序输出,如果原代码在构造data对象时先放userId再放itemId,你按相反顺序去JSON.stringify,得到的字符串不一样,签名结果也就错了。
我当时的处理办法是:不自己拼对象,直接复用页面里的data对象——在断点处把options.data打印出来,照着这个对象的结构和属性顺序,在脚本里用同一个顺序构造。这样可以最大限度避免序列化顺序不一致的问题。
4.4 secretKey的定位:搜索关键字+找常量池
secretKey是整个还原过程中最费时间的一环。藏得比较深,不是直接写在generateH5Sign旁边的。我的思路是:
- 在vendor.js里搜索
secret、appKey、signKey等关键字 - 找到一处疑似配置对象的地方,断点查看它的属性
- 确认
secretKey是从哪个对象里取的,再看这个对象的值是怎么初始化的
最终找到的配置对象大概是这样的:
var signConfig = { appKey: "27736905", secretKey: "b9a3f1c8e84d4f7a9b2c6e5d1a8f0c3b", version: "1.0.0" };这个secretKey就是参与MD5拼接的固定盐值。在自动化脚本里,直接把这一串复制过去就行,因为它是静态的。但我猜测它可能会随版本更新变化,所以脚本里要预留配置项,方便后期替换。
5. 完整签名算法还原:从抓包到可复现的脚本
5.1 把算法整理成一份可执行的JS函数
当上面的分析都跑通之后,我把它整理成了这样一个Node.js环境下可执行的函数:
const crypto = require("crypto"); function getH5Sign({ token, timestamp, data }) { const secretKey = "b9a3f1c8e84d4f7a9b2c6e5d1a8f0c3b"; const baseStr = `${token}&${timestamp}&${JSON.stringify(data)}`; return crypto.createHash("md5").update(baseStr + secretKey).digest("hex"); } // 使用示例 const token = "abc123"; // 从cookie x5sec中截取 const timestamp = String(Date.now()); const data = { itemId: "123456", userId: "888888" }; const sign = getH5Sign({ token, timestamp, data }); console.log(sign);这个函数跑出来的结果跟抓包里的h5sign一致,说明算法还原成功了。但要注意,如果你在浏览器环境里跑,crypto模块需要用js-md5之类的库替代,浏览器没有Node内置的crypto。
5.2 验证阶段的几个对比样本
为了确认算法没问题,我用三组不同参数做对比验证:
| 测试场景 | 请求参数 | 时间戳 | 期望h5sign | 实际计算值 | 是否一致 |
|---|---|---|---|---|---|
| 商品详情1 | itemId=123456 | 1688123456789 | 9f2c8b1d... | 9f2c8b1d... | 一致 |
| 商品详情2 | itemId=654321 | 1688123456799 | 2a3b4c5d... | 2a3b4c5d... | 一致 |
| 用户上报点击 | clickType=1 | 1688123456899 | 7a8b9c0d... | 7a8b9c0d... | 一致 |
三组全部通过,可以认为还原完成。但这里要提醒一下:验证时要确保时间戳和请求头里的时间戳完全一致,否则算出来的签名必然对不上。我用的是抓包记录里的原始时间戳,不做任何修改,拿到本地算完再比对,这样不会受时间窗口影响。
5.3 请求头组装的其他配套参数
h5sign并不是孤立的,实际操作中还需要配套其他请求头参数。我整理了一份常用请求头样本,方便对照检查:
User-Agent: Mozilla/5.0 (Linux; Android 10; ...) Referer: https://www.goofish.com/ Cookie: x5sec=abc123_def456; cna=xyz789 x-sign: 9f2c8b1d... x-mini-wua: ... x-ttid: ...除了h5sign,还有x-sign、x-mini-wua这些参数也参与风控校验。就我的经验来说,如果只是做普通的数据采集,h5sign算对之后请求基本能通;但如果你把访问频率拉得太高,就算h5sign正确,风控依然可能拦截。所以算法还原只是第一步,请求的节奏和行为模拟也要跟随真实用户习惯。
6. 自动化落地:把h5sign还原过程封装成服务
6.1 做一个简单的签名服务
手动还原只能证明算法正确,真正要落地还得封装成服务。我用Node.js写了一个极简的HTTP服务,接收参数、返回签名:
const http = require("http"); const crypto = require("crypto"); const server = http.createServer((req, res) => { if (req.url.startsWith("/sign")) { const params = new URLSearchParams(req.url.split("?")[1]); const token = params.get("token"); const timestamp = params.get("timestamp"); const data = params.get("data"); const sign = crypto.createHash("md5") .update(`${token}&${timestamp}&${data}${secretKey}`) .digest("hex"); res.end(sign); } else { res.end("ok"); } }); server.listen(3000);这样其他语言写的采集脚本,比如Python,只需要请求一下http://localhost:3000/sign?token=xx×tamp=xx&data=xx就能拿到签名,避免了跨语言重复造轮子。接口很简陋,但作为内部工具已经够用了。
6.2 Python侧集成示例
Python侧集成时,我用requests直接调用签名服务,再把签名塞进请求头,效果是可以正常请求接口的:
import requests import time import json token = "abc123" # 从cookie中截取 timestamp = str(int(time.time() * 1000)) data = {"itemId": "123456"} sign_service = "http://localhost:3000/sign" resp = requests.get(sign_service, params={ "token": token, "timestamp": timestamp, "data": json.dumps(data) }) h5sign = resp.text headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "h5sign": h5sign, "Cookie": f"x5sec={token}_def456" } response = requests.get("https://www.goofish.com/...", headers=headers) print(response.status_code)跑通之后,整个还原流程算是正式闭合成一条可复用的链路。但要注意:签名服务只是把算法还原了,不意味着你能不受限制地调用接口。风控的维度很多,签名只是其中一环,滥用照样会被封号。
6.3 Cookie自动获取与续期策略
cookie里的x5sec是会过期的,实测闲鱼的x5sec有效期大概在几十分钟到几小时不等。我一开始是手动复制cookie到脚本里,后来发现太麻烦,改成了半自动方案:用Playwright启动隐身浏览器,自动登录闲鱼H5页面,从上下文中读取cookie,再传给签名服务。
from playwright.sync_api import sync_playwright def get_x5sec_cookie(): with sync_playwright() as p: browser = p.chromium.launch(headless=True) context = browser.new_context() page = context.new_page() page.goto("https://www.goofish.com/") cookies = context.cookies() browser.close() for c in cookies: if c["name"] == "x5sec": return c["value"].split("_")[0]这个方案能让采集脚本在cookie过期前自动补充,整体可用性提升不少。不过Playwright启动浏览器比较重,实际部署时可以放到定时任务里,每隔一段时间刷新一次cookie。
7. 踩坑合集:这不是一帆风顺的还原之路
7.1 明明算法对了,签名却对不上
这个问题困扰了我将近一天。算法逻辑已经跟JS里完全一致了,但算出来的MD5就是和抓包里的不一样。后来我把参与拼接的每一段字符串都打印出来逐段核对,才发现问题出在JSON序列化上——原JS里data对象的属性顺序是userId在前,itemId在后,而我自己构造对象时是反过来的。
查完这个问题我总结了一个教训:做逆向还原时,不要想当然地“重新构造参数”,最稳妥的方式是直接在断点处把原始参数对象打印出来,照着对象的实际属性和顺序去复刻。任何自以为是的调整,都可能在签名结果上被放大。
7.2 版本更新导致旧参数失效
还有一次,h5sign突然怎么都校验不过,过了几个小时才意识到可能是前端代码更新了。重新下载了新的JS文件,对比了一下发现,getCookieToken里的截取规则变了,从split("_")[0]改成了split("_")[1]。
这种版本变化没有规律可循,唯一的应对策略就是:定期检查h5sign是否还能正常生成,如果发现突然大面积失败,优先怀疑前端代码更新,而不是算法实现的问题。把JS文件拉下来,全局搜索一下h5sign,通常几十分钟内就能定位到变化点。
7.3 别忽略风控的其他信号
还要提醒一句:即使h5sign完全正确,也不能保证请求一定成功。闲鱼的风控系统会综合IP、设备指纹、行为轨迹、请求频率等多个维度做判断。曾经我用一组高匿代理IP去请求接口,一开始正常,跑了一晚上之后全部被拦截,换成固定住宅IP才恢复。
所以做数据采集一定要控制节奏,单IP并发不要太高,请求间隔尽量模拟真实用户行为。h5sign只是签名校验这一环,整个风控链路是立体的,别把宝全押在签名上。
8. 工具选型与调试效率方法论
8.1 三种主要工具的分工
逆向调试的过程中,我同时用了几个工具,各有分工:
| 工具 | 用途 | 备注 |
|---|---|---|
| Charles | HTTPS抓包 | 看请求头、请求体,分析参数结构 |
| Chrome DevTools | JS断点调试 | 打断点、看调用栈、观察变量 |
| Node.js + js-md5 | 算法复现与验证 | 快速验证签名算法是否正确 |
Charles负责“外围”的请求视角,DevTools负责“内核”的代码视角,Node.js负责“验证”的算法视角。三者结合才能高效定位问题。如果只依赖其中一个,很容易陷入盲人摸象的状态。
8.2 断点调试的三个技巧
断点调试是整个逆向过程的核心,这里分享三个我非常受用的技巧:
- XHR断点设置
*:拦截所有请求,不漏掉任何可疑调用。 - 在函数入口打断点:不要在一堆代码中间打断点,要从函数入口逐行走,这样能完整看到参数从进入到输出的全过程。
- 善用Scope面板:每走一步,看一眼Scope里的变量值变化,很多隐藏逻辑会在变量变化过程中暴露出来。
8.3 遇到混淆代码怎么办
闲鱼的部分JS代码有轻度混淆,变量名是a、b、c之类的缩写,函数名也比较抽象。遇到这种代码,我不会逐行去读,而是先在调用栈里找到自己关心的函数,打断点观察输入输出,用黑盒的方式理解它。只有当黑盒推断与实际结果不一致时,才需要去细扣代码逻辑。
如果遇到重度混淆,我建议先在本地格式化JS文件,再用关键字搜索定位到可疑函数附近,逐步还原逻辑。重度混淆下,很多控制流会被打平,读起来很痛苦,但耐心的状态机分析依然能解决。
9. 复盘:h5sign还原的核心方法论
这次h5sign还原的经历,本质上是把一串加密参数拆解成可预测的规则。拆解过程中最有价值的能力不是“会看代码”,而是建立从现象到原理的反推路径。
整个路径可以概括为以下四步:
- 抓包观察:收集多组请求样本,找参数变化的规律,锁定参与签名的输入变量。
- 断点追踪:从请求触发点反推函数调用链,定位签名函数,用作用域变量验证猜测。
- 参数固定:逐段确认参与签名的字符串的精确取值,包括截取规则、时间戳精度、序列化方式。
- 脚本验证:把算法转成独立脚本,用抓包样本做盲测,确保输出的签名与真实请求完全一致。
这套方法论不光适用于h5sign,也适用于大部分H5页面的签名还原。只要你掌握了“抓包->断点->验证”这条链路,换一个参数、换一个平台,本质上都是同一套打法。
最后分享一个我自己的经验:逆向过程中最大的敌人不是加密强度,而是耐心。很多参数藏得并不深,但你得一层层去拨开周围的扰动,才能真正看到它的核心。遇到暂时解不开的情况,先放一放,把抓包样本收集齐,再回头看代码,往往会有新的线索。
这篇日记就写到这里。如果你也在还原闲鱼或其他H5页面的签名参数,希望这份记录能给你一点参考。有新的思路或者踩到新的坑,欢迎讨论。