简介:该资源是一套针对某东平台h5st参数逆向的实战代码包,面向有一定JavaScript与爬虫基础、想研究webpack打包环境下签名生成逻辑的开发者。压缩包共2个文件,包含1个Python脚本和1个JavaScript脚本,分别用于接口请求流程模拟与webpack模块加载逻辑分析,可在本地直接运行调试。资源包仅159KB,轻量聚焦,适合快速上手。已有855人学习或下载。内容围绕某东webpack版本的h5st逆向完整方案展开,代码注释清晰,可直接替换关键参数用于爬虫数据采集时生成合法签名;同时也演示了如何从webpack入口定位加密函数、跟踪调用链、还原参数生成过程,帮助读者理解逆向分析的基本思路与断点调试技巧。需要说明的是,逆向技术应仅用于学习与安全研究,请勿用于非法用途。
1. 别急着找代码:先把某东的 webpack 型 h5st 逆向思路理顺
如果你真正逆向过京东 h5st 的 webpack 产物,大概率会遇到这样的场景:从网页里拿到一段压缩后的 JS,格式化后满屏__webpack_require__,搜h5st只能搜出三两个字段名,可断点一跑,调用栈深度超出预期。这就是 webpack 方式的 h5st 逆向破解最磨人的地方——算法不是不存在,而是被打散成几十个模块,靠模块加载器串联。很多人拿到别人分享的“完整代码”之后直接复制粘贴,结果请求照样 3 开头报错,原因就是没有把 webpack 模块装载、环境指纹、签名拼接这三层关系理清。
这篇笔记就是围绕这三件事展开的:先讲清楚在 webpack 种产物里定位 h5st 入口的基本思路,然后落到可复现的断点流程和 Node 还原方案,最后给出我自己踩过、也看别人反复踩的坑。适合已经能看懂压缩 JS、想自己跑通一次签名还原的前端工程师和爬虫方向从业者。顺着这个思路走一遍,你拿到的就不只是一段代码,而是一条能跟着调参的完整链路。
2. 准备 webpack 逆向的环境与工具:先从 bundle 里认识 h5st 的藏身方式
2.1 两种 webpack 打包优化配置下的产物形态,决定了你用哪种方式下手
h5st 这个签名参数最早出现在京东 H5 端的请求里,后来移动端、小程序端也都有类似逻辑。但从 webpack 方向做逆向,你面对的核心对象是webpackJsonp或__webpack_modules__这类自执行函数。常见打包配置不同,产物长什么样差别很大。
- 第一种是 mode 为 development 的未压缩包,模块 ID 是数字递增,有
eval包裹,每个模块顶部有注释/*!********************!**!***!这类提示,模块之间依赖相对好读。 - 第二种是 mode 为 production 的压缩包,模块 ID 会被重新映射成短 ID,变量名全部替换成
e, t, n这种单字母,整个 bundle 可能压成一行。
从实战看,京东 H5 端线上环境基本都是第二种。所以你第一步不是研究算法,而是先判断手里的文件属于哪一种。判断方式很简单:在浏览器 Sources 面板里找到相关 JS 文件,格式化后看是否有webpackJsonp.push调用——有,说明还是旧的 webpack 3/4 风格;如果没有webpackJsonp,而是直接执行(self.webpackChunk_xxx = self.webpackChunk_xxx || []).push,就是 webpack 5 的新式 chunk 加载写法。这两种形态下,hook 模块加载器的位置不一样。
// 针对 webpack 4 传统产物 if (window.webpackJsonp) { const oldPush = window.webpackJsonp.push.bind(window.webpackJsonp); window.webpackJsonp.push = (chunk) => { // chunk[0] 是模块 id 列表,chunk[1] 是模块函数表 console.log('chunk loaded, module ids:', chunk[0]); return oldPush(chunk); }; } // 针对 webpack 5 产物 const oldChunkPush = Array.prototype.push; if (self.webpackChunk_xxx) { self.webpackChunk_xxx.push = function(chunk) { console.log('webpack 5 chunk:', chunk[0]); return oldChunkPush.apply(this, arguments); }; }这段代码是写在浏览器 Console 里的预加载脚本,目的是在你点击页面触发 h5st 生成之前,先把模块加载动作拦截下来,看它到底加载了哪些模块。不要小看这一步,很多人在压缩包里花几个小时找加密函数,其实用这个办法能直接拿到模块 ID 列表,配合后面的关键字搜索,能大幅缩小范围。注意这里的webpackChunk_xxx前缀不是固定的,每个站点可能不同,你需要先在 Console 里输入Object.keys(webpackChunk_xxx)或Object.keys(self).filter(k => k.startsWith('webpackChunk'))来确定真实名字。
2.2 抓包定位:从 Network 面板里锁定 h5st 字段出现的请求特征
在动手写代码之前,我习惯先抓一次完整请求,因为 h5st 不是独立接口,它是附加在商品详情、搜索、加购等业务请求里的一段查询参数或请求头。打开无痕窗口,进入商品详情页,在 Network 面板里筛选api.m.jd.com这类域名,随便点开一个请求,看看它的 Query String Parameters。
你会发现参数里有一长串类似h5st=20240612123456789;...;...的字符串,按分号分成几段。不同版本的 h5st 分段数量不同,有的 5 段,有的 7 段,有的 10 段。这个分段数很关键,因为后面你比对算法生成结果时,段数不一致基本等于算法版本选错。同时记下这个请求的完整 URL、method、body 摘要,它们共同参与签名计算。
# 如果你习惯用命令行抓包,可以先用 curl 复现一次请求,把响应和请求头落到文件里 curl -k 'https://api.m.jd.com/client.action?functionId=xxx&appid=pc-item-soa' \ -H 'User-Agent: Mozilla/5.0 ...' \ -H 'Referer: https://item.jd.com/100012043978.html' \ --data 'body={"skuId":100012043978}' \ -o response.txt注意这个 curl 命令只是一个通用模板,真正的请求参数需要从浏览器里复制。我一般用这样的方式:在 Network 里右键对应请求,选择 Copy as cURL,然后贴到本地文件里保存,不要直接在命令行重放。一是因为浏览器里的 cURL 包含了完整 cookies,重放时容易带上多余参数干扰判断;二是我们要的不是请求本身,而是确认 h5st 生成的触发时机——它到底是页面加载时就生成,还是在点击某个按钮后才生成。这个触发时机决定了你 hook 断点的位置。
2.3 从安卓逆向视角回头看:为什么 webpack 型比 Java 层还原更直接
顺带说一句,很多做安卓逆向的同行拿到京东 APP 的 APK,第一反应是去libjdbitmapkit.so里找 Java 层调用,或者用objection来 hookcom.jingdong.common.utils.H5Security这类类,思路没错,但那是移动端的路数。H5 端的 webpack 型 h5st 走的是纯 JS 生成链路,不需要 JNI 调用,也不涉及 so 文件的导出函数。
这就带来一个优势:JS 是解释执行的,你可以在浏览器里动态插桩,把每一步中间值都打印出来。不像安卓逆向那样需要 Frida 脚本去 hook 原生层,或者面对混淆后的 dex 字节码。反过来也有劣势:纯 JS 里做字符串编码、常量展开、数组轮转这类混淆非常方便,而且 webpack 打包后会把不同功能拆到多个模块里,模块间通过__webpack_require__相互调用,静态读代码等于读一本没有目录的手册。
所以我的建议是:不要一开始就钻进压缩包里逐行读。先用浏览器的 Debugger 功能配合 XHR/fetch 断点,让 h5st 在生成的那一刻停下来,再顺着调用栈往回找。这个过程在 js 逆向圈里的叫法是“跟栈溯源”。下面章节我会给你一套具体的跟栈路径。
3. 定位 webpack 模块:从调用栈反推 h5st 入口的完整断点流程
3.1 用关键字锁定模块 ID:h5st、timestamp、fingerprint 的三种搜法
在 Sources 面板里打开压缩后的 webpack bundle,按Ctrl+Shift+F全局搜索h5st。如果你的目标是 PC 端商品详情页,通常能搜到几十处结果,包括setH5st、getH5st、h5stData等。这时候别急着点进去,先把搜索结果里所有出现h5st的行导出到一个临时文件,对着看。
真正的签名生成函数往往不在业务代码里,而在公共模块里。一个常用技巧是搜索那些更底层的字符串特征,比如timestamp、random、fingerprint,或者搜请求参数里的常量字段。因为签名最终要拼成key=value;...的字符串,所以搜;分隔符的拼接逻辑也能命中。我一般会同时搜三个关键字:h5st、fingerprint、appId(注意大小写),三个结果的交集基本就是算法所在区域。
// 这是在 Console 里快速统计模块里出现 h5st 字段数量的脚本 // 在格式化后的 JS 文件开头执行,能帮你定位哪个闭包里提到 h5st 次数最多 (function () { const modules = __webpack_require__.c; // webpack 4 模块缓存 let results = {}; for (const id in modules) { const fnStr = modules[id].toString(); const hits = (fnStr.match(/h5st/g) || []).length; if (hits > 0) { results[id] = hits; } } console.table(results); })();这段脚本的前提是你已经打断点停在某个模块内部,能访问到__webpack_require__的上下文。如果直接运行报错,说明当前作用域没有暴露这个变量。另一个更简单的方法是使用 Chrome 的getEventListeners或直接在调用栈里右键选择Store as global variable,让控制台生成一个temp1对象,然后再在这个对象上递归遍历寻找 webpack 运行时。经验是:webpack 模块函数内部都有一个module.exports,你要找的加密函数一定挂在某处的exports上。
3.2 从接口入口下断点:XHR/fetch 断点远比硬搜字符串快
与其在几百个模块里猜哪个是 h5st 生成器,不如反过来:先在 Network 面板里找到真正发起业务请求的那一行代码,然后让浏览器在 XMLHttpRequest 调用处断下。操作路径是:Sources 面板右侧的 XHR/fetch breakpoints,点 + 号添加 URL 关键字,比如输入client.action,这样任何请求只要 URL 里包含这个关键字就会被自动断住。
断住之后,看右侧 Call Stack 面板,你会看到一串调用链:从send方法一路到某个业务封装函数。这中间可能经过sign、interceptors、request等包装层。往下点几层,找到那一段正在拼参数的代码,多半就是 h5st 被塞进请求头/查询串的位置。在那里设一个普通断点,然后重新发起请求,就能看到 h5st 的完整生成上下文。
这里有一个很常见的误判:你以为找到了h5st赋值的代码就是算法入口,其实那只是业务层把结果填到参数里。真正的算法封装在一个返回 Promise 的异步函数中,赋值行只是await generatedH5st(...)的调用。所以要在赋值行点进去,进入被调用函数内部继续跟。跟进去之后,通常能看到几个典型步骤:取时间戳、取随机数、取设备指纹值,然后统一交给一个核心密码算法函数。
3.3 在 Node 里模拟 webpack 模块装载:把浏览器端验证变成本地可复现脚本
如果你只能在一个浏览器环境里验证,会很麻烦。业内常见的做法是:把整个 webpack bundle 保存为本地 JS 文件,在 Node.js 里用vm模块模拟浏览器环境执行它。难点在于 bundle 内部可能引用了window、document、navigator等浏览器 API,你需要预先 mock 一批最小实现。
伪代码的思路如下:
const vm = require('vm'); const fs = require('fs'); const code = fs.readFileSync('./webpack_bundle.js', 'utf-8'); const sandbox = { window: {}, document: { cookie: '', getElementById: () => null, createElement: () => ({ style: {}, getContext: () => null }) }, navigator: { userAgent: 'Mozilla/5.0 ...', language: 'zh-CN' }, location: { href: 'https://item.jd.com/100012043978.html' }, console, setTimeout, clearTimeout }; sandbox.window = sandbox; vm.createContext(sandbox); vm.runInContext(code, sandbox); // 执行完 bundle 后,从沙箱里取出 webpack 运行时 const webpackRequire = sandbox.webpackJsonp ? null : findRequire(sandbox);这里最麻烦的是findRequire这个函数,把它写清楚需要几页纸。实战中更快的路径是直接拷贝别人已经写好的webpackRequireFactory,它本质上是通过遍历沙箱中所有全局变量,找到__webpack_require__函数引用。找到之后,就可以按模块 ID 逐个调用模块,看哪个模块导出对象里有h5st方法。整个过程是可行的,但注意一点:bundle 执行时可能会检测当前环境是否浏览器,单纯 mock 的navigator容易被看出端倪,导致某些模块初始化报错。这时候不要硬刚,回去用浏览器里的真实值填充各类属性。
4. 还原 h5st 签名生成核心流程:token、指纹与 body 摘要的拼接顺序
4.1 三个必调参数:时间戳、随机数和应用标识,缺一个签名就废
当你在调用栈里找到核心生成函数时,不要急着抄代码。先把函数的入参、出参和闭包变量记录下来。就我目前还原过的 h5st 版本来看,主流程里至少会有以下三类输入:
- 时间戳:通常以毫秒级字符串传入,也可能先被
Math.round或Date.now()生成后格式化。 - 随机数:有的版本叫
random,有的叫rnd,生成方式一般是Math.random().toString(36).slice(-8)或自定义的伪随机函数。 - 应用标识:常见的是
appId、functionId、body内容摘要。这里 body 不是完整 JSON,而可能经过序列化、去空格、排序后计算。
入口函数把你传进来的对象{ appId, functionId, body, timestamp, random }按一定顺序拼成中间字符串。顺序很关键,常见的做法是把多个字段用&或;连接,随后对这个中间字符串做 HmacSHA256。你逆向的目标之一就是还原出这个拼接模板,因为它直接决定签名能不能对上。
4.2 从混淆代码里识别出哈希和编码函数:不用读完整实现,认特征
webpack 打包后的 h5st 算法里,核心计算部分通常不会以明文HmacSHA256出现,而是被封装成类似l[pt](t, n, s)这样的调用。你可以在函数内部打断点,通过看传入的字节长度和输出字节长度来判断它到底是 MD5、SHA1 还是 SHA256。另一个更实用的是对输出结果做长度判断:32 位十六进制的多半是 MD5,40 位是 SHA1,64 位是 SHA256。
找准之后,你不需要把整个 hash 函数逐行看懂,只需要确认它调用了某个标准的hmac实现,就可以用 Node.js 内置的crypto模块替代。同样,base64 编码特征更明显,无非是_和-替换/和+的变体,很多版本还会把末尾的=去掉。把这些字符替换规则记录下来,后面还原代码时原样复刻即可。
const crypto = require('crypto'); // 伪代码:根据中间字符串生成签名字段 function signWithHmac(secretKey, message) { return crypto.createHmac('sha256', secretKey) .update(message, 'utf8') .digest('hex'); } // h5st 里的 base64 变体:有些版本会把字符集替换掉 function encodeBase64Variant(input) { return Buffer.from(input, 'utf8') .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); }注意那段encodeBase64Variant是通用的 Base64 URL 安全变体,不是从特定站点里抄出来的。你真正逆向时,要以断点看到的输出格式为准来调整替换规则。常见坑是有些版本只替换+/但不删=,有些版本反过来从不替换字符只做 URL 编码,甚至有用toString('base64')之后再自行打乱字符序的。这些差异必须靠对比输入输出来验证,不能想当然。
4.3 把算法流程封装成可调用的 Node 函数:从浏览器验证到本地批量生成
一旦确认了拼接模板和密码算法,就可以写一个独立的 Node 函数,把整个过程固定下来。我一般按四个阶段组织代码:参数规整、中间串拼接、加签计算、结果格式化。
function generateH5st(params) { // 1. 参数规整 const timestamp = params.timestamp || String(Date.now()); const random = params.random || Math.random().toString(36).slice(-8); // 2. 拼接中间串:顺序是踩坑踩出来的,不要自己调整 const raw = [ `appId:${params.appId}`, `functionId:${params.functionId}`, `body:${params.body}`, `timestamp:${timestamp}`, `random:${random}` ].join(';'); // 3. 加签 const secretKey = params.secretKey; // 从算法里抠出来的常量 const sign = signWithHmac(secretKey, raw); // 4. 格式化成 h5st 字段的标准形态 return `appId:${params.appId};timestamp:${timestamp};random:${random};sign:${sign}`; }给出这个示例不是让你直接拿去用——每个版本的字段名和顺序都不同。你真正要做的是把断点里抓到的入参逐字段填进去,让函数跑出的结果和被断点截获的真实 h5st 一致。这个一致性的验证条件很苛刻:如果随机数算法还原得不对,每次结果都不可能一致;如果指纹值没有参与签名,换成不同浏览器环境跑结果依然相同。所以当你能在 Node 里复现同一请求的相同 h5st 值时,才说明顺序和算法都正确了。
同时记住,本地还原过程中不要在代码里写死你的个人信息,例如用户名、手机号、真实 UA。否则换一台机器、换一个浏览器跑,签名立刻失效,反而会误导你以为是算法错了。
5. 避坑记录:h5st 逆向中常见的 5 个翻车现场
5.1 环境指纹与算法版本不匹配:拿到旧版本代码套不上新接口
现象:你从某个分享帖里找到一份能跑通的 h5st 生成代码,换上自己的参数后却一直返回请求非法。
原因:京东的 h5st 在不同业务线、不同端、不同时期有多个版本。例如 PC 端和移动端 H5 的算法入口模块 ID 不一样;appId对应不同的密钥盐;甚至同一个页面在灰度发布时新旧版本会并存一段时间,你调试的 bundle 是老版本,但接口校验已经切换到新版本。
解决:不要迷信任何“完整代码”。判断版本最直观的办法是看 h5st 生成结果的分段数量与你当前环境抓到的分段数量是否一致。如果生成结果是 5 段,线上是 7 段,说明版本不对。老老实实用当前页面最新 bundle 重新走一遍定位流程,替换掉旧模块。
5.2 时间戳和服务器时间偏差:本地时间被改动导致签名验不过
现象:代码在电脑 C 端跑没问题,放到云服务器上跑就报“签名过期”。
原因:h5st 签名里包含时间戳,服务端会校验时间窗。你的服务器时区不是东八区,或系统时间没有同步 NTP,导致客户端生成的时间戳和服务器时间差了几分钟,签名直接被判定为过期。
解决方法:生成前用服务器端时间戳兜底,不要直接依赖Date.now();同时加上时间校准逻辑,比如从页面响应头里获取服务器时间,和本地时间做差值后统一修正。我在代码里通常写成getServerTimeOffset()函数,保证生成签名时用的是校准过的时间。
5.3 断点跟错层:把封装调用当成算法入口,浪费一下午
现象:跟栈到某一层发现h5st=被赋值,以为找到算法核心,结果读代码发现只是把外部传入的结果塞进参数对象里。
原因:业务代码和算法代码分离,赋值层只是使用者。你在 Call Stack 中看到的调用层级有限,某些异步链还会被 Promise 截断,导致真正生成函数在上一个 tick 里已经执行完。
解决:不要把断点停在赋值行就结束。右键该行选择 “Jump to definition”,进入函数创建的位置;如果发现这个函数已经被执行完,回到调用栈更上层,或直接在 Console 里手动调用生成函数并打印结果来确认。
5.4 URL 编码导致 body 摘要不一致:明明是同样参数,生成的签名却不同
现象:抓包对比发现,自己拼的 body 字符串和浏览器实际发送的不一致。
原因:body 中如果有中文字符、空格、嵌套 JSON,浏览器在序列化时可能做了encodeURIComponent处理,而你在 Node 里直接用原始字符串计算摘要,两边不一致。
解决:从抓包里完整拷贝Request Payload原文,不要手工重新输入。然后用它作为签名函数的入参。如果业务请求是非 JSON 格式的表单,注意字段顺序也会影响摘要,先按抓包里的顺序排列,不要按字母序重排。
5.5 webpack 缓存模块 ID 不稳定:每次刷新页面模块顺序都变
现象:今天定位到模块 3099 是 h5st 核心,明天再刷新发现那个模块变成了别的函数。
原因:生产环境 webpack 打包时会用moduleIds: 'deterministic'或hashedModuleIds来生成稳定的模块 ID,但如果你加载的 chunk 顺序变化、或命中了不同的 splitChunks 策略,模块 ID 可能被重新映射。
解决:不要用模块 ID 作为唯一标记,而是记录模块函数的toString()结果中的特征字符串,比如某个常量值或 b64 特征。下次重新定位时,通过搜索这个特征字符串快速找回对应模块,而不是记 ID。这个习惯能帮你省下很多返工时间。
6. 最后的验证与进阶:让本地生成脚本在真实请求上跑通一次
验证是痛苦但绕不开的一步。我建议你写一个最小验证脚本,直接从浏览器复制一个未带 h5st 的原始请求,在本地用还原出来的函数补上 h5st,然后用 Node 的fetch或 Python 的requests重放一次。判断标准是:响应码不再出现签名非法,且返回体的业务字段与你刚才在浏览器看到的保持一致。
import time import requests def make_h5st(clock, random, body): # 该方法用你还原的 JS 逻辑实现,Python 里可以用 execjs 调用 Node 函数 return gen_h5st_via_node(clock, random, body) # 从浏览器复制来的原始请求参数 url = "https://api.m.jd.com/client.action" payload = { "functionId": "pc_item_soa", "appid": "pc-item-soa", "body": '{"skuId":100012043978}', "timestamp": str(int(time.time() * 1000)), } payload["h5st"] = make_h5st(payload["timestamp"], "abc12345", payload["body"]) resp = requests.get(url, params=payload, headers={...}) print(resp.status_code, resp.text[:200])这里强调一下:execjs只是桥接方案,生产环境不建议直接把 Node 代码包进 Python 服务,性能和维护都很差。更可靠的做法是把核心算法用 Python 重写一遍,或者起一个独立的 Node 签名服务,通过本地 HTTP 接口对外提供签名能力。这样后续算法更新时,你只改 Node 端,调用方无感知。
我一贯的习惯是,每还原完一版算法,就录一屏生成结果和真实抓包结果对比的视频或者日志。这个看似多此一举的举动,在后续算法升级时特别管用——你可以对照旧日志快速定位是拼接顺序变了、密钥变了还是指纹算法变了。写代码的人总有自负时刻,我也是在连续踩了两周坑之后才养成这个习惯。希望帮到你,少走这段弯路。
本文还有配套的精品资源,点击获取