☰
webpack打包H5签名逆向实战:h5st生成与破解完整指南
2026/11/2 2:26:59 网站建设 项目流程

简介:这份资源面向具备一定前端基础、希望深入理解移动端加密参数生成机制的爬虫学习者,聚焦某东平台以 webpack 方式打包的 h5st 逆向分析场景,提供可运行的完整代码示例,帮助读者从打包结构入手理清参数生成与调用链路。压缩包共 2 个文件,包含 1 个 Python 脚本与 1 个 JavaScript 文件,分别承担请求侧调用与前端逻辑还原的职责,整体约 159KB,体量轻便、便于对照调试。目前已有 858 人学习下载,说明该方向在实战群体中具有一定关注度。读者可从中获得 webpack 模块加载方式的分析思路、h5st 相关逻辑的代码还原参考,以及 Python 与 JS 协同验证的排错路径,适合作为逆向工程与爬虫技术结合的学习材料,在合法合规前提下用于安全测试与原理探究。

1. 从一次抓包说起:这个 webpack 打包的 H5 签名项目到底在解决什么

某次帮朋友排查一个移动端页面的数据采集问题,抓包工具里请求参数干干净净,唯独多了一个叫h5st的字段,长度几十个字符,每次刷新都变。把 JS 全量下载下来一看,满屏webpackJsonp、__webpack_require__,模块被切得七零八落,变量名全是_0x开头。这就是典型的 webpack 打包 + 混淆的 H5 签名场景,也是这份「实战篇-某东之 webpack 方式的 h5st 逆向破解完整代码」要处理的对象。它面向的是已经会写爬虫、但卡在动态签名这一关的开发者:目标不是教你 Python 语法,而是把「签名怎么生成、参数怎么拼、代码怎么在本地复现」这条链路完整走通,让你拿到源码后能对着自己的目标页面改参数、改算法、改调用方式。适合谁?写过 requests、用过抓包工具、能看懂基本 JS 语法,但一遇到 webpack 模块化和控制流平坦化就发怵的人。

这份代码的价值在于「完整」两个字。网上零散的帖子往往只贴一段扣出来的函数,缺上下文、缺初始化、缺环境依赖,粘到自己项目里跑不通。而一个可复现的逆向工程,必须包含:定位签名入口、还原 webpack 模块加载、补齐浏览器环境(window、document、navigator 等)、处理时间戳与随机数、最终产出可被服务端接受的h5st。下面按「先看清结构 → 再跑通流程 → 最后能改」的顺序拆,中间会给出关键代码段和踩坑记录,新手照着敲能出结果,熟手能直接看架构取舍。

2. 项目结构与技术栈:webpack 模块化代码是怎么组织的

2.1 目录与文件职责

这类逆向项目通常不会是一个庞大工程,而是围绕「还原 + 复现」组织的一小组文件。常见做法是分成三层:抓取层、还原层、调用层。抓取层负责把目标页面的 JS 资源拉下来并做初步格式化;还原层负责把 webpack 的模块注册表展开、把混淆的字符串解密;调用层负责在 Node 里模拟浏览器环境并调用签名函数。一个典型的目录长这样:

h5st-reverse/ ├── src/ │ ├── fetch/ # 拉取目标 JS、保存原始 bundle │ │ └── download.js │ ├── deob/ # 反混淆:字符串解密、控制流还原 │ │ ├── string-decrypt.js │ │ └── module-extract.js │ ├── runtime/ # 浏览器环境模拟 │ │ ├── env.js │ │ └── sandbox.js │ └── sign/ # 签名入口封装 │ └── h5st.js ├── data/ # 抓下来的原始 bundle 与中间产物 ├── test/ # 对比验证脚本 └── package.json

fetch/download.js只做一件事:给定页面地址,把 HTML 里引用的 JS 全部下载到data/,并按 URL 哈希命名,避免重复。deob/下两个文件是核心,string-decrypt.js处理_0x字符串表,module-extract.js把 webpack 的__webpack_require__调用图还原成可读的模块列表。runtime/env.js负责造window、document、location、navigator这些签名函数会读的对象。sign/h5st.js是对外暴露的唯一入口,输入业务参数,输出签名字符串。

技术栈上,运行环境是 Node.js(建议 16 以上,因为部分混淆代码用了较新的语法),依赖极少:axios或原生https拉资源,jsdom可选(如果签名逻辑强依赖 DOM),vm模块用来隔离执行不可信代码。不引入重型反混淆框架,是因为目标 bundle 的混淆程度可控,手写规则比通用工具更精准,也更好调试。

2.2 webpack 模块加载机制还原

webpack 打包后的代码,本质是一个自执行函数,内部维护一个modules对象,键是模块 id,值是函数。运行时通过__webpack_require__(id)取模块。逆向的第一步不是读业务逻辑,而是把这个加载器「接管」过来,让它在 Node 里能正常跑。常见做法是找到 bundle 里的function __webpack_require__(moduleId)定义,把它连同modules对象一起抽出来,挂到一个沙箱上下文里。

// runtime/sandbox.js const vm = require('vm'); const fs = require('fs'); function createSandbox(bundlePath) { const code = fs.readFileSync(bundlePath, 'utf-8'); // 造一个最小浏览器环境,签名函数通常只读这几个 const sandbox = { window: {}, document: { cookie: '', createElement: () => ({}) }, navigator: { userAgent: 'Mozilla/5.0 ...' }, location: { href: 'https://example.com/' }, setTimeout, clearTimeout, console, }; sandbox.window = sandbox; // 很多代码用 window.xxx 也直接用全局 xxx vm.createContext(sandbox); vm.runInContext(code, sandbox, { timeout: 5000 }); return sandbox; }

这段代码的关键在sandbox.window = sandbox这一行。webpack 产物里大量出现window.xxx和裸xxx混用,如果不把 window 指向自身,就会出现「undefined is not a function」。vm.createContext把沙箱隔离出来,timeout防止死循环把主进程拖死。参数说明:bundlePath是抓下来的 JS 路径;userAgent要和你实际请求时带的一致,否则部分签名会校验 UA 指纹导致结果对不上。

加载器接管之后,下一步是找到签名模块的 id。做法是在 bundle 里搜h5st字符串,往上找最近的__webpack_require__调用,或者直接遍历modules对象的键,逐个执行看哪个返回对象里带签名方法。这一步没有银弹,靠的是耐心和断点。

3. 签名算法拆解:从参数拼接到 h5st 生成

3.1 定位入口与参数结构

签名入口通常是一个导出对象上的方法,形如modules[1234].default.sign(params)。定位到之后,先别急着跑,把它的入参结构打印出来。常见做法是在沙箱里包一层代理,拦截对签名函数的调用,把实参序列化后落盘。

// sign/h5st.js function wrapSign(sandbox, moduleId) { const mod = sandbox.__webpack_require__(moduleId); const original = mod.default.sign; mod.default.sign = function (...args) { console.log('sign args:', JSON.stringify(args, null, 2)); return original.apply(this, args); }; return mod.default; }

打印出来的参数一般包含:业务字段(如商品 id、页码)、时间戳、随机串、以及一个版本号。h5st的生成逻辑通常是「把参数按固定顺序拼接 → 加盐 → 做哈希或加密 → 再拼上时间戳和版本」。这里最容易翻车的是拼接顺序和分隔符,差一个字符结果就完全不同。血泪经验是:不要凭猜,直接在原始函数里下断点(Node 里用--inspect配合 Chrome DevTools),看它实际怎么拼。

3.2 时间戳、随机数与版本号处理

签名里几乎必然包含时间戳,而且服务端会校验时效性(常见窗口是几分钟)。这意味着你复现出来的签名不能缓存复用,每次请求都得重新生成。随机数部分有的用Math.random(),有的用Date.now()派生,还有的读performance.now()。在 Node 沙箱里,performance默认不存在,需要补:

sandbox.performance = { now: () => Date.now() % 100000 }; sandbox.Math = Object.create(Math); sandbox.Math.random = () => 0.123456789; // 调试期固定,方便对比

调试阶段把随机数固定成常量,是为了让两次运行的输出可比对,确认除随机数外的逻辑都一致。等验证通过再放开。版本号字段一般硬编码在 bundle 里,搜version或直接看签名结果的前几位,改版本号往往会导致服务端拒绝,所以不要随意动。

3.3 完整调用链路与结果验证

把上面几步串起来,一个完整的签名调用流程是:创建沙箱 → 加载 bundle → 取签名模块 → 构造业务参数 → 调用签名 → 拿到h5st→ 拼进请求头发起真实请求 → 对比响应是否正常。

// test/verify.js const { createSandbox } = require('../runtime/sandbox'); const { wrapSign } = require('../sign/h5st'); const sandbox = createSandbox('data/bundle.js'); const signer = wrapSign(sandbox, 1234); const params = { skuId: '10001', page: 1, timestamp: Date.now(), }; const h5st = signer.sign(params); console.log('generated h5st:', h5st); // 再用 axios 带上 h5st 请求,检查返回码

验证方法很直接:用生成的签名发一次请求,看返回是正常数据还是「签名错误」。如果报错,优先排查三件事:时间戳是否在服务端窗口内、UA 是否和签名时一致、参数拼接顺序是否和原始函数完全一致。这三项占了失败原因的八成以上。

4. 避坑与排查:五个真实翻车现场

4.1 沙箱里window is not defined

现象:加载 bundle 直接抛ReferenceError: window is not defined。原因:bundle 顶层就有window.xxx的读取,而沙箱上下文里没挂 window。解决:在vm.createContext之前就把window、self、globalThis都指向同一个对象,并且保证document、navigator至少有最小实现,哪怕字段是空的。

4.2 签名结果每次都不一样但服务端都接受

现象:两次调用输出不同,但请求都成功。原因:签名里含随机数或时间戳,服务端只校验格式和时效,不要求唯一。这不是 bug,别浪费时间追求「固定输出」。解决:验证阶段固定随机数只为比对逻辑,生产调用时保持随机即可。

4.3 本地跑通、换台机器就失败

现象:同一份代码,A 机器正常,B 机器报签名错误。原因:navigator.userAgent或时区、语言环境被签名逻辑读取并参与计算。解决:把 UA、时区(process.env.TZ)、语言都显式写死,不要依赖运行环境默认值。这是最隐蔽的一类坑,排查时先 diff 两边的环境变量。

4.4 混淆字符串解密后仍是乱码

现象:把_0x字符串表解出来后,拼出来的还是看不懂的内容。原因:解密函数依赖一个初始化数组,而该数组在 bundle 加载时被push过多次,直接静态提取会漏掉运行时的偏移。解决:不要静态抠字符串表,让 bundle 在沙箱里完整跑一遍,再在运行时拦截解密函数的返回值,拿到的是最终态。

4.5 请求带签名仍返回风控页

现象:签名格式正确,但响应是验证页而非数据。原因:除了h5st,服务端还校验 cookie 里的其他标识、请求头顺序、甚至 TLS 指纹。解决:先用真实浏览器抓一份完整请求,逐项对比 header 和 cookie,把缺失的字段补齐;签名只是其中一环,不要把它当成万能钥匙。

5. 二次开发与改造:把这份代码变成你自己的工具

拿到这份代码后,最实际的改造方向有三个。第一是参数化:把硬编码的skuId、page、moduleId抽到配置文件里,用命令行参数或环境变量传入,这样同一套代码能跑不同目标。第二是接口化:把sign/h5st.js包一层 HTTP 服务(用express或原生http),对外提供POST /sign,入参是业务字段,出参是h5st。这样 Python 爬虫侧只需要发一个请求拿签名,不用在 Node 和 Python 之间来回倒腾。第三是健壮性:给沙箱加载加超时和重试,给签名调用加异常捕获,避免单个页面改版导致整个采集任务挂掉。

// server.js —— 把签名能力暴露成 HTTP 接口 const http = require('http'); const { createSandbox } = require('./runtime/sandbox'); const { wrapSign } = require('./sign/h5st'); const sandbox = createSandbox('data/bundle.js'); const signer = wrapSign(sandbox, 1234); http.createServer((req, res) => { let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { try { const params = JSON.parse(body); const h5st = signer.sign(params); res.end(JSON.stringify({ h5st })); } catch (e) { res.statusCode = 500; res.end(JSON.stringify({ error: e.message })); } }); }).listen(3000);

这个服务的价值在于解耦:签名逻辑集中在 Node 侧,采集逻辑留在你熟悉的语言里。改造时注意两点:沙箱只创建一次并复用,不要每个请求都重新加载 bundle(性能差十倍以上);签名函数如果不是纯函数(内部有状态累积),要考虑并发安全,必要时加锁或每次调用前重置状态。

验证改造是否成功,我一般会写一个对比脚本:用改造后的接口生成签名,和原始沙箱直接调用生成的结果做 diff,在固定随机数的前提下两者必须完全一致。这个「后悔药」式的回归测试,能在你改参数、升级依赖、目标页面小改版时第一时间发现问题。从那以后我每次动签名相关代码,都强制先跑一遍这个对比脚本再提交,省了太多半夜排查的功夫。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询