尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

webpack方式破解h5st签名:Node.js补环境完整代码链路

发布时间:2026/9/29 21:15:11

资讯中心
01
ARTICLE

webpack方式破解h5st签名:Node.js补环境完整代码链路

webpack方式破解h5st签名:Node.js补环境完整代码链路
简介面向爬虫与前端逆向学习者的实战代码包聚焦某东平台基于webpack方式打包的H5ST签名算法重点解决采集过程中加密参数生成与校验难题。压缩包共2个文件包含1个Python脚本与1个JavaScript文件其中JS文件对应webpack模块加载、算法核心逻辑及参数拼接过程Python脚本用于补全浏览器环境、调用JS接口并输出最终签名整体仅159KB轻量易用。目前已有852人学习下载适合具备一定JS逆向基础、希望快速掌握webpack打包特征与H5ST生成流程的开发者。通过该代码可直观理解如何从webpack模块中定位加密入口、还原函数调用关系、构造合法请求参数并可直接对接爬虫框架提取数据学习后可大幅减少自行调试与逆向分析的时间快速落地实际项目。两个文件虽少但覆盖了从环境补齐、签名生成到参数使用的完整闭环对正在研究某东反爬机制或webpack逆向的读者具备较高参考价值。1. 入手webpack方式的h5st逆向破解一条能摆脱浏览器壳子的路某电商平台的h5st签名参数在2024年之后几乎成了所有自动化采集脚本的拦路虎。你带齐了cookie和UA接口照样返回sign error。缺的就是网页里那段webpack打包出来的JS在运行时生成的h5st。别急着上无头浏览器杀内存不说指纹还容易漂。更稳的做法是把页面里webpack打包的算法工程整个搬到Node.js补上缺失的浏览器环境让函数在本地直接产出签名。这条路线就是webpack方式的h5st逆向破解。这里给出一套可复现的完整代码链路从抓包定位、扣bundle、补环境、导出算法函数到自研签名替换与回归验证。适合已经会抓包想把手动操作变成服务化接口的前端、自动化采集工程师。2. 定位h5st签名链路从抓包到选型为什么webpack路线更适合生产2.1 先抓三组请求确认h5st的输入输出我接到一个具体需求的时候不会一上来就下载网页JS。第一步永远是抓包。用代理工具把目标接口的完整链路记录下来至少要抓三组间隔10秒的一组、间隔5分钟的一组、换登录账号后的一组。抓包的时候同时把页面HTML保存下来后面提取script会用到。观察点有三个。第一h5st出现在哪个位置是URL query、请求头还是表单体。第二在接口参数不变的情况下隔一段时间h5st是否变化如果变化说明里面带了时间戳或随机数。第三换账号之后h5st长度和前缀有没有变化判断里面是否绑定了token或用户指纹。为了让样本留得规整我会用mitmproxy挂一个拦截脚本把带h5st的请求连同完整请求头落盘成JSONL方便后面做回归样本。# h5st_capture.py # mitmproxy 插件拦截带 h5st 的请求把现场完整落盘 import json from mitmproxy import http def request(flow: http.HTTPFlow) - None: if h5st not in flow.request.query: return record { url: flow.request.url, method: flow.request.method, headers: dict(flow.request.headers), timestamp: flow.request.timestamp_start } with open(samples/h5st_sample.jsonl, a, encodingutf-8) as f: f.write(json.dumps(record, ensure_asciiFalse) \n)这段脚本只做拦截和落盘不做任何改写。flow.request.query是mitmproxy里请求query参数的容器判断“h5st”是否在参数里比直接搜URL字符串更可靠能避免把路径里其他同名片段误判成签名。timestamp_start是请求进入代理的时刻单位是秒后面换算成毫秒对比时间戳时记得乘以1000。这一步做完你会得到三组完整的请求样本完整URL、请求头、请求体、页面HTML。这些样本是后续回归验证的基线。我一般会按samples/20250110_1.json这种方式归档避免后面验证时找回不了当时的现场。从抓包结论看某东的h5st出现在商品详情、价格、搜索等关键接口的query参数里由页面JS在请求发起前同步生成。服务端拿到请求后会先校验h5st再返回业务数据。只要h5st不对无论cookie多完整都是白搭。2.2 三种常见实现路线为什么我选webpack补环境处理h5st这类页面签名市面主流是三条路AST还原算法、无头浏览器执行、webpack补环境执行。我一个个说下成本。AST还原是把混淆的加密算法直接还原成可读代码优点是没有运行时依赖缺点是工作量大、周期长而且某东的h5st核心算法在每次版本更新后都可能调整还原一次的成本很高。无头浏览器方案是用playwright或puppeteer直接加载页面、触发请求、截获h5st优点是实现快缺点是内存占用高并发一上来机器就吃紧而且指纹环境每次都要重新生成。我自己的选择是webpack补环境。原因很简单h5st的算法本身就是webpack模块只要把webpack的运行时搬到Node.js让它在模块加载时认为自己在浏览器里那么生成h5st的函数就能被直接调用。这个方案的优点是保留了原始算法逻辑不需要还原每一行混淆代码资源占用和无头浏览器比低不少。下面是三个方案的对比方便你拍板实现路线开发工作量运行时资源版本更新后的维护成本适合场景AST还原算法高低高每次版本要重新分析算法长期不变无头浏览器执行低高每个并发一个浏览器实例低小批量、临时验证webpack补环境中低纯Node进程中需跟随chunk结构变化服务化接口、批量采集结论是如果你要做的是长期稳定服务而不是临时跑一两个请求webpack补环境是性价比最高的入口。后面所有代码都是按这个路线展开的。2.3 在webpack打包产物里定位h5st算法模块某东的前端工程是典型的webpack多chunk结构。打开页面源码能看到一堆script标签主bundle先执行h5st相关的算法往往被放到独立的chunk里通过jsonp异步加载。这是webpack打包优化配置里splitChunks的常规做法把体积大且不常改的业务模块拆开让首屏只加载必要代码。找算法模块的办法是搜特征字符串。用编辑器或者命令行把下载好的JS全部搜一遍优先搜“h5st”这个关键词通常能命中变量名、字符串常量或注释。命中后用括号匹配把包含算法函数的整个模块块看一遍。webpack 4时代的模块签名形如h5st_algorithm_id: (function(module, exports, __webpack_require__) { // 这里是加密算法实现 })如果搜“h5st”命中太多再叠加搜索“generate”“sign”“md5”“sha”这类关键词缩小范围。实际操作里h5st那部分代码往往和token、timestamp拼接是同一个模块找到之后把模块id记下来后面注入导出代码时要用它。这里有个很玄学的经验有些压缩后的bundle里“h5st”字符串会出现几十次但真正的算法函数名可能被替换成_0xabc123这种形式所以别只看函数名要顺着字符串常量的引用关系往上找。3. 把webpack bundle搬到Node.js扣代码、补环境、导出函数的完整代码3.1 从页面HTML里下载全部webpack bundle这一步的目标是把页面上所有外链JS下载到本地。我习惯写一个一次性脚本直接读保存的HTML文件正则提取script src然后批量下载。下面这个download.js我在多个项目里复用改下页面路径就能跑。// download.js // 从本地html提取script src并批量下载webpack bundle到./bundle目录 const fs require(fs); const path require(path); const https require(https); const http require(http); const html fs.readFileSync(./samples/page.html, utf-8); const scriptRex /script[^]src([^])/g; const srcList []; let match; while ((match scriptRex.exec(html)) ! null) { const url match[1]; if (url.startsWith(http) || url.startsWith(//)) { srcList.push(url.startsWith(//) ? https: url : url); } } function download(url, outPath) { const mod url.startsWith(https) ? https : http; return new Promise((resolve, reject) { const options { headers: { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36, Referer: https://item.jd.com/ } }; mod.get(url, options, (res) { const chunks []; res.on(data, (c) chunks.push(c)); res.on(end, () { fs.writeFileSync(outPath, Buffer.concat(chunks)); console.log(saved:, outPath); resolve(outPath); }); }).on(error, reject); }); } (async () { fs.mkdirSync(./bundle, { recursive: true }); for (const url of srcList) { const fileName ./bundle/ path.basename(url.split(?)[0]); await download(url, fileName); } console.log(done, total files:, srcList.length); })();这段代码做了三件事读HTML、正则抽出所有外链script、逐个下载到bundle目录。两个参数建议注意一下。headers里的Referer必须填目标页面域名否则部分CDN直接403User-Agent最好和你抓包时保持一致因为某些时段服务端会按UA下发不同版本的chunk。文件名直接用URL里的basename去掉query参数避免问号造成路径问题。下载完成后检查一下bundle目录里的文件数量。正常情况下主bundle之外的几个chunk也都会在这里。如果某个文件特别小只有几KB那可能是入口脚本不要漏掉。如果页面里有一堆第三方埋点脚本可以只挑主bundle和h5st相关chunk放进后续加载列表减少补环境时遇到的无关依赖。3.2 用vm沙箱补环境把webpack运行时跑起来bundle文件对浏览器全局环境有强依赖。直接扔进Node的global里跑第一行就会报window is not defined。我采用vm模块建沙箱把常见的浏览器全局对象先塞进去然后在沙箱里执行bundle代码。下面是node-runner.js的骨架最小到能把webpack运行时立起来。// node-runner.js // 最小补环境让webpack bundle在vm沙箱里认为自己在浏览器中 const fs require(fs); const vm require(vm); const files [ ./bundle/main.bundle.js, ./bundle/h5st.chunk.js ]; const sandbox { console, setTimeout, clearTimeout, setInterval, clearInterval, Buffer, location: { href: https://item.jd.com/100012043624.html, host: item.jd.com, protocol: https: }, navigator: { userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, platform: Win32, language: zh-CN, languages: [zh-CN, zh, en], vendor: Google Inc. }, document: { cookie: , referrer: , createElement() { return { tagName: canvas, getContext: () ({ measureText: () ({ width: 0 }) }), toDataURL: () , getBoundingClientRect: () ({ width: 0, height: 0 }) }; } }, localStorage: { getItem: () null, setItem: () {}, removeItem: () {} }, sessionStorage: { getItem: () null, setItem: () {}, removeItem: () {} } }; sandbox.window sandbox; sandbox.self sandbox; sandbox.globalThis sandbox; vm.createContext(sandbox); for (const file of files) { const code fs.readFileSync(file, utf-8); vm.runInContext(code, sandbox, { filename: file }); console.log(loaded:, file); } console.log(webpackJsonpPush:, typeof sandbox.webpackJsonpPush); module.exports { sandbox };几个关键点。sandbox.window sandbox要在createContext之前做这样bundle里的window、self、globalThis都指向同一个对象否则“window is not defined”和“self is not defined”会轮番出现。document.createElement返回的对象必须带getContext和measureText因为指纹算法会真的去调用canvas一旦返回undefined后续代码只能崩。localStorage和sessionStorage用最简单的桩实现先把代码跑通真实值后面再补。如果最后一行打印出的webpackJsonpPush类型是function说明webpack运行时已经在沙箱里就位。这时候还没法直接调用h5st因为算法模块可能还没执行或者执行了但没暴露到全局。下一步就是注入调试模块把函数导出来。3.3 注入调试入口把h5st生成函数导出为可调用函数webpack的jsonp运行时暴露了一个入口webpackJsonpPush。它的签名是(chunkIds, moreModules, executeModules)我们借助它注册一个自己的调试模块在模块代码里拿到__webpack_require__进而去require h5st算法模块再把结果挂到沙箱的globalThis上。下面是inject-h5st.js的核心注入代码。// inject-h5st.js // 在vm沙箱里注入调试模块把h5st算法函数暴露到globalThis const vm require(vm); const { sandbox } require(./node-runner); const injectCode (function() { var debugModules {}; debugModules[debug_h5st] function(module, exports, __webpack_require__) { var h5stModule __webpack_require__(h5st_algorithm_id); globalThis.__genH5st function(input) { return h5stModule.generate(input); }; }; webpackJsonpPush([], debugModules, [[debug_h5st]]); })(); ; vm.runInContext(injectCode, sandbox); if (typeof sandbox.__genH5st function) { console.log(__genH5st ready); } else { console.error(inject failed, check module id or load order); }三个参数的含义是第一个空数组表示这个调试模块不属于任何已有chunk第二个是模块表键就是模块id第三个是要在模块加载后立即执行的模块id列表。webpack运行时收到这个push后会把debug_h5st模块放进modules表然后执行它。模块函数三个参数里__webpack_require__就是webpack内部的require用它去加载真实的h5st算法模块。注意这里的“h5st_algorithm_id”必须替换成你在2.3节里定位到的真实模块id。如果id是数字就写数字不用引号或者在数字外包一层String转换。inject之后如果打印出来的是function说明导出成功。如果报module not found多半是目标模块在那个chunk还没加载检查files数组里有没有包含它对应的chunk文件。导出的__genH5st此时已经具备生产签名能力可以先手动调用一次入参用抓包请求里看到的token、时间戳等信息。调用成功后再进入下一步的算法还原与替换。4. 还原签名算法源串拼接规则、必调参数与自研替换代码4.1 hook算法入口把入参出参记录成回归样本有了可调用的__genH5st之后第一件事不是去分析它内部怎么写而是记录它每次被调用时的输入输出。这里在导出函数外再包一层recorder。下面这段hook-recorder.js代码会把最近200次调用的参数和结果缓存在内存里同时打印到控制台。// hook-recorder.js // 在原算法外做参数记录保留最近200条调用日志 function createRecorder(realFn) { const records []; return function recorder(...args) { const startedAt Date.now(); const result realFn.apply(this, args); const entry { invokeTime: startedAt, args: args.map(a { if (a null || a undefined) return String(a); if (typeof a object) return JSON.stringify(a); return String(a); }), result: typeof result string ? result : JSON.stringify(result) }; records.push(entry); if (records.length 200) records.shift(); console.log(h5st invoke:, entry.invokeTime, -, entry.result.slice(0, 40)); return result; }; } // sandbox 来自 inject-h5st.js 执行后的上下文 sandbox.__genH5st createRecorder(sandbox.__genH5st);这里把入参做了序列化对象会转成JSON字符串普通类型转成原始字符串。result只打前40个字符避免输出太长刷屏。拿到一批调用记录后用抓包样本和这些日志对齐就能确认几个关键问题h5st是否依赖时间戳相同入参在不同时间调用结果是否不同、是否依赖指纹换指纹结果是否变化、签名长度和格式是否稳定。对齐的方式很简单把抓包样本里请求发出时的参数原样传给__genH5st如果输出和线上h5st一致说明环境已经完整如果不一致优先检查当前沙箱里的时间戳、指纹和线上生成时是不是同一套。这一步的产出是一张入参出参对照表后面还原源串时全靠它。4.2 签名源串的通用规律与参数调优表做过多个电商平台签名逆向之后你会发现这类签名参数的生成逻辑高度相似取一批易变字段按固定顺序拼接再做一次摘要算法最后加上时间戳前缀。h5st的核心就落在源串拼接顺序和摘要算法上。下面是h5st这类参数里最常见的参与字段以及它们各自的行为属性参数名数据来源变化频率是否必调说明appId站点固定标识基本不变必调用于区分业务线参与源串timestamp页面发起请求前取服务器时间秒级变化必调签名里通常直接带明文时间戳token登录态下发的令牌数小时到数天必调绑定用户身份参与源串fingerprint页面指纹算法生成每次会话可能变化高度影响最不稳定建议整体导出指纹生成函数businessParams当前接口业务参数每个请求都不同选调按key排序后参与拼接调优时最需要注意的是timestamp。我一般在签名生成时不用本地Date.now()而是从页面接口的响应头或页面初始化数据里取服务器时间避免本地时钟偏差导致服务端校验失败。fingerprint则不建议手工固定因为同一账号换环境后指纹变化是弱风控信号反而容易被关注。4.3 完整代码里的替换方案用自研签名函数换下原算法在已经能用原算法生产合法h5st的前提下如果业务方提出要把生成逻辑沉淀成自己的服务就可以考虑自研替换。实现方法是用原生Node加密模块重写hash过程替换掉webpack里那段混淆代码。这里给一个可运行的示意实现。// self-sign.js // 自研签名替换拼接规则按抓包样本反推hash算法按实际样本确认 const crypto require(crypto); function buildSign({ appId, timestamp, token, fingerprint, businessParams }) { const sortedQuery Object.keys(businessParams) .sort() .map(k encodeURIComponent(k) encodeURIComponent(businessParams[k])) .join(); // 示例顺序真实顺序务必以抓包反推为准 const source [appId, timestamp, token, fingerprint, sortedQuery].join(); return crypto.createHash(sha256).update(source, utf-8).digest(hex); } function genH5st(input) { const timestamp input.timestamp || Date.now(); const sign buildSign({ ...input, timestamp }); return timestamp _ sign; } module.exports { genH5st };这段代码能不能直接跑通取决于一件事buildSign里的source拼接顺序和hash算法是否与你从样本中反推出来的一致。反推方法是控制变量——固定其他字段只改token观察输出是否变化再固定token只改businessParams的某个键再观察变化。这样逐字段试下来就能把源串的构成顺序试出来。自研替换的好处是彻底摆脱了webpack bundle的依赖代码体积小、可读性强、便于服务化部署。风险是需要保持和平台版本同步一旦平台改了签名算法你需要重新跑一轮反推。我一般会在自研替换代码外面保留原算法调用入口作为版本升级时的对照基准这个习惯帮我省过很多排查时间。4.4 一份“完整代码”工程到底包含哪些文件很多人问“完整代码”是不是指把整个webpack bundle脱敏后贴出来。不是。工程意义上的完整代码是让一个没接触过这个项目的人按步骤拉下来就能跑通整条链路的一套工程文件。一个标准目录应该是这样的h5st-reverse/ download.js # 下载页面bundle node-runner.js # 补环境并加载bundle inject-h5st.js # 注入调试入口导出算法函数 hook-recorder.js # 记录算法入参出参 self-sign.js # 自研签名替换实现 verify.js # 回归验证脚本 samples/ # 抓包样本至少三组实际交付时我会把bundle目录和抓包样本目录单独放不进版本库因为bundle体积大而且会随版本更新。真正进版本库的是download.js、node-runner.js、inject-h5st.js、self-sign.js和verify.js这五个脚本文件配合README里的抓包说明一个新的同事照着流程走一天之内能跑通。5. 避坑清单webpack方式跑h5st最容易翻车的7个细节下面这7条是我在h5st这个项目上攒下来的血泪经验按遇到频次排序每一条都是真实翻过车的。5.1 现象Node里跑bundle第一行就报window is not defined原因webpack bundle的顶层代码在初始化阶段就开始访问window用来做环境探测。Node原生没有window所以第一行就崩。解决在构建sandbox时提前把window挂到sandbox自身而且必须在vm.createContext之前完成。注意window、self、globalThis要指向同一个对象三者缺一都会在后续代码里以不同名称再崩一次。sandbox.window sandbox; sandbox.self sandbox; sandbox.globalThis sandbox; vm.createContext(sandbox);5.2 现象navigator或canvas相关调用返回undefined指纹计算直接中断原因指纹算法会调用canvas的getContext、measureText、toDataURL还会读取字体列表和屏幕参数。桩对象如果没按调用方期望的结构实现后面的属性访问会抛TypeError。解决canvas的桩不能只返回一个空对象。getContext要返回带measureText方法的上下文measureText要返回带width属性的对象toDataURL要返回字符串。这样指纹算法至少能走完调用链即使数值不是真实值也不至于中断。如果页面代码里还调用了document.createElement(div)统一返回同一个对象一般也不会出问题因为业务代码通常只操作style和innerHTML这类属性。5.3 现象webpackJsonpPush执行了但导出的算法函数是undefined原因目标算法模块不在主bundle里而在后续通过jsonp加载的chunk中。files数组漏掉了对应chunk或者模块id写错。解决先确认bundle目录下所有文件都已加载再检查注入代码里的模块id和2.3节定位到的是否一致。如果id是数字要转成数字传入而不是字符串。还有一个土办法在注入模块里用Object.keys(webpack_require)打印一下已注册的模块id列表对照列表确认目标id的写法。5.4 现象本地生成的h5st偶尔能过校验偶尔被拒绝原因签名里带了timestamp但本地用Date.now()生成和请求发出时的服务器时间差了几秒。服务端一般允许的窗口很小超了就拒绝。解决把timestamp改成请求发出前从服务端获取的时间或者从接口响应头的date字段取时间。签名生成时间要和请求发出时间尽量贴近差一两秒以内最稳妥。如果拿不到服务端时间至少保证Node服务器的系统时钟和北京时间同步并在启动命令里设置TZAsia/Shanghai。5.5 现象签名结构看起来一致但服务端就是校验失败原因指纹值不真实。很多指纹算法会读取cookie里的设备标识、localStorage里的历史值直接用字符串常量替代会破坏签名源串。解决把指纹生成函数和h5st生成函数一起导出而不是只导h5st。让指纹函数自己在沙箱里跑一遍把过程中读取的cookie键、localStorage键全列出来再对照浏览器真实环境补齐。这一步比较繁琐但绕不过去指纹是h5st里最影响校验结果的部分。5.6 现象本地跑得好好的一换服务器就报各种is not defined原因新服务器上Node版本、系统架构不同webpack bundle里可能用到了Buffer等Node全局类型或者依赖了时钟、时区设置。解决在node-runner.js里显式传入Buffer、setTimeout等Node全局对象到sandbox。时区问题建议在启动命令里统一设置TZAsia/Shanghai避免时间偏移影响签名。换机器后先跑一遍verify.js不要直接上生产流量。5.7 现象所有请求都成功但一段时间后账号被风控限制原因签名本身没被识破但请求频率、指纹复用和业务行为暴露了自动化特征。签名逆向解决的是参数合法性解决不了行为风控。解决控制单位时间请求量让指纹、token、UA、IP保持一致来绑定环境并及时更换新指纹。这块没有银弹只能自己摸索平台容忍阈值。遇到风控先停半小时查指纹新鲜度和请求频率而不是盲目加并发。6. 回归验证三件套把本地签名和线上样本钉死6.1 在抓包样本上跑回归我把抓包存档的三组请求整理成三条case分别记录接口URL、完整参数、线上h5st。verify.js每次改动后都会跑这三条case对比本地生成的h5st和线上h5st的差异。样本线上h5st前缀本地h5st前缀timestamp差值是否一致case1a3f2...a3f2...1s一致case28c91...8c91...0s一致case3f5b0...f5b0...1s一致如果前缀一致、timestamp差在2秒内说明签名链路基本可信。前缀不一致则优先检查指纹和拼接顺序。6.2 把自研签名接进请求管道用自研签名替换后请求管道里只需要一行调用。我习惯把token、fingerprint从登录态里取出来统一放到一个session对象里请求发出前从这里拿值算签名。// http-client.js const { genH5st } require(./self-sign); function buildRequest(url, businessParams, session) { const timestamp Date.now(); const h5st genH5st({ appId: session.appId, timestamp, token: session.token, fingerprint: session.fingerprint, businessParams }); return { url: url ?h5st encodeURIComponent(h5st), headers: { User-Agent: session.userAgent, Cookie: session.cookie } }; }这段代码的要点是让timestamp尽可能靠近真实请求时间并且在session里持久化token和fingerprint。注意不要每请求都重新生成指纹那样会造成指纹和cookie绑定关系跳变反而触发风控。6.3 边界意识签名能过不代表行为合法这套代码我只建议用在自己有权限的合法采集场景里。签名能过参数校验只是解决技术门槛不意味着可以无节制请求。我自己一般会让业务侧监控同一套token下每小时的成功率一旦出现明显的成功率滑坡先停半小时查指纹新鲜度和请求频率而不是盲目加并发。逆向签名这条路做成服务容易做成稳定服务难难就难在边界意识。希望帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。