十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

闲鱼H5逆向实战:从零还原h5sign签名算法

闲鱼H5逆向实战:从零还原h5sign签名算法 1. 写在前面为什么会碰这个参数搞过闲鱼H5逆向的朋友应该都有印象抓包时请求头里总跟着一个h5sign参数一串类似MD5的32位字符不按常规出牌。我最初以为就是个简单的签名把参数丢进MD5就完事了结果折腾下来发现这里面藏的弯弯绕比想象中多。这篇日记记录的是我从零开始还原h5sign生成逻辑的完整过程包含抓包定位、JS断点调试、加密函数追踪、动态参数比对、以及最终的自动化脚本落地。适合对Web逆向有一定基础、但对闲鱼H5签名机制还比较陌生的朋友参考。先说结论h5sign本质上是一个与请求参数、时间戳、Cookie中的关键值强绑定的签名串生成逻辑藏在首页加载的某个webpack模块里需要从调用栈反推入口再逐层剥离混淆才能看到全貌。整个过程中最坑的其实不是加密算法本身而是参数来源的追踪——如果你不知道h5sign里到底掺了哪些原料就算拿着算法代码也没法稳定复现。接下来我把整个还原过程按时间线拆开每一步都标注了当时的思路和踩坑记录希望能帮你少走点弯路。2. 第一步用抓包锁定h5sign的生成场景2.1 从Fiddler到Charles的切换我刚开始用的是Fiddler但闲鱼H5的流量走的是HTTPSFiddler装证书后还是有一批请求解不开后来换成Charles才好一点。这里给新手一个建议抓H5包优先用Charles证书装好后记得在手机上信任描述文件同时打开SSL Proxying并添加goofish.com域名否则你会看到一堆加密乱码根本没法分析。抓包后我主要观察两类接口商品详情页的item.htm相关的异步加载接口用户操作行为的上报接口比如说点击、滑动、曝光这些这两类接口的请求头里都带h5sign同一个页面、同一个用户请求不同接口时h5sign的值不一样。这说明它不是固定的必然跟请求本身有关。2.2 先看特征h5sign它到底长什么样我截取了几组数据对比比如访问同一个商品页时请求A GET /gw/mtop.taobao.detail.getdetail/6.0/?itemId123456 HTTP/1.1 h5sign: 9f2c8b1d3a6e4f7b8c0d2e5a1f3b4c6d cookie: x5secabc123; cnaxyz789请求B GET /gw/mtop.taobao.detail.getdetail/6.0/?itemId654321 HTTP/1.1 h5sign: 2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d cookie: x5secabc123; cnaxyz789同样cookie不同itemIdh5sign完全不同。我当时第一反应就是h5sign至少跟请求参数有关大概率是把参数序列化后做了某种摘要。但这只是最外层的猜测。因为Cookie里的x5sec是风控下发的令牌一般签名算法里也会掺这种动态值进去具体怎么掺还需要从JS里找答案。2.3 把搜索范围缩到最小在动手逆向之前我先用文件搜索的思路过滤了一遍。闲鱼的H5包一般由多个JS文件组成全部下下来在本地搜h5sign是能搜到的但在线调试更直接——在Chrome DevTools的Sources面板里按CtrlShiftF全局搜索h5sign能看到它在那些JS里出现过。我实际搜索的结果是h5sign关键字出现的位置主要集中在两个文件里app.jswebpack打包后的入口文件体积最大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而不是常见的keyvalue拼接。如果你是直接把参数对象转成查询字符串再签名那结果必然不对。还要特别注意对象属性顺序。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实际计算值是否一致商品详情1itemId12345616881234567899f2c8b1d...9f2c8b1d...一致商品详情2itemId65432116881234567992a3b4c5d...2a3b4c5d...一致用户上报点击clickType116881234568997a8b9c0d...7a8b9c0d...一致三组全部通过可以认为还原完成。但这里要提醒一下验证时要确保时间戳和请求头里的时间戳完全一致否则算出来的签名必然对不上。我用的是抓包记录里的原始时间戳不做任何修改拿到本地算完再比对这样不会受时间窗口影响。5.3 请求头组装的其他配套参数h5sign并不是孤立的实际操作中还需要配套其他请求头参数。我整理了一份常用请求头样本方便对照检查User-Agent: Mozilla/5.0 (Linux; Android 10; ...) Referer: https://www.goofish.com/ Cookie: x5secabc123_def456; cnaxyz789 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?tokenxxtimestampxxdataxx就能拿到签名避免了跨语言重复造轮子。接口很简陋但作为内部工具已经够用了。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: fx5sec{token}_def456 } response requests.get(https://www.goofish.com/..., headersheaders) 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(headlessTrue) 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 三种主要工具的分工逆向调试的过程中我同时用了几个工具各有分工工具用途备注CharlesHTTPS抓包看请求头、请求体分析参数结构Chrome DevToolsJS断点调试打断点、看调用栈、观察变量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页面的签名参数希望这份记录能给你一点参考。有新的思路或者踩到新的坑欢迎讨论。
返回列表