资讯动态

Webpack型京东h5st逆向实战:从入口定位到签名还原全流程

发布时间:2026/10/14 4:49:56 来源:尧图企业网站定制
简介该资源是一套针对某东平台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。你会发现参数里有一长串类似h5st20240612123456789;...;...的字符串按分号分成几段。不同版本的 h5st 分段数量不同有的 5 段有的 7 段有的 10 段。这个分段数很关键因为后面你比对算法生成结果时段数不一致基本等于算法版本选错。同时记下这个请求的完整 URL、method、body 摘要它们共同参与签名计算。# 如果你习惯用命令行抓包可以先用 curl 复现一次请求把响应和请求头落到文件里 curl -k https://api.m.jd.com/client.action?functionIdxxxappidpc-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 用关键字锁定模块 IDh5st、timestamp、fingerprint 的三种搜法在 Sources 面板里打开压缩后的 webpack bundle按CtrlShiftF全局搜索h5st。如果你的目标是 PC 端商品详情页通常能搜到几十处结果包括setH5st、getH5st、h5stData等。这时候别急着点进去先把搜索结果里所有出现h5st的行导出到一个临时文件对着看。真正的签名生成函数往往不在业务代码里而在公共模块里。一个常用技巧是搜索那些更底层的字符串特征比如timestamp、random、fingerprint或者搜请求参数里的常量字段。因为签名最终要拼成keyvalue;...的字符串所以搜;分隔符的拼接逻辑也能命中。我一般会同时搜三个关键字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 位十六进制的多半是 MD540 位是 SHA164 位是 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, paramspayload, headers{...}) print(resp.status_code, resp.text[:200])这里强调一下execjs只是桥接方案生产环境不建议直接把 Node 代码包进 Python 服务性能和维护都很差。更可靠的做法是把核心算法用 Python 重写一遍或者起一个独立的 Node 签名服务通过本地 HTTP 接口对外提供签名能力。这样后续算法更新时你只改 Node 端调用方无感知。我一贯的习惯是每还原完一版算法就录一屏生成结果和真实抓包结果对比的视频或者日志。这个看似多此一举的举动在后续算法升级时特别管用——你可以对照旧日志快速定位是拼接顺序变了、密钥变了还是指纹算法变了。写代码的人总有自负时刻我也是在连续踩了两周坑之后才养成这个习惯。希望帮到你少走这段弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑