简介:一份专注某东平台webpack方式H5ST逆向破解的实战代码包,主要面向爬虫工程师、前端安全研究者及逆向爱好者。资源围绕webpack模块化打包的H5ST生成逻辑,演示如何结合Chrome DevTools分析请求、定位加密入口,并梳理webpack模块映射关系,最终给出可运行的破解代码。压缩包共2个文件,包含1个Python脚本与1个JavaScript文件:JS文件还原了核心加密算法,Python脚本负责辅助调试与输出验证,整体仅159KB,体积紧凑却覆盖从入口定位到算法还原的关键链路。已有852人学习下载,资源提供清晰的代码注释与逆向分析思路,可帮助读者快速掌握webpack环境下H5ST的还原方法,并延伸到类似平台接口加密参数的破解实践;通过该案例,还能学习断点调试、请求抓包与代码格式化等逆向工程基本技能。需要强调,逆向学习应在法律允许范围内进行,遵守平台规则与版权约定。
1. 某东 webpack 方式 h5st 逆向破解:一份能直接跑通的全增量代码
做电商数据采集的同行应该都有印象:某东的 h5st 签名是道绕不过去的坎。早期是简单的 sign 拼接,后来升级成了动态令牌,每次请求参数都变,直接断点跟栈能跟到怀疑人生。更头疼的是它的 JS 被 webpack 打包过,不是那种一眼能看出逻辑的普通文件,动辄几千行模块互相引用,抠代码时经常出现“函数调用了、变量却 undefined”的玄学问题。这篇笔记要拆的这份代码,正好把这条链路完整走了一遍——从定位加密入口、跟栈找参数拼装点,到用 webpack 模块化方式还原算法,再在 Node.js 环境里把签名算出来。适合已经被 h5st 卡了一两天、对 JS 逆向有基础但没摸过 webpack 打包产物的人。它不是理论分析,是能直接复现的实战过程,坑都踩过了,照着走就行。
2. 先立住原理:webpack 打包下的 h5st 加密封装与识别方式
2.1 h5st 算法演进与 webpack 打包的关联
h5st 从早期版本到现在,核心变化是引入了时间戳、随机数、指纹参数拼接,再经过一套自研的哈希与编码逻辑生成签名。它躲在window._jd123这类全局变量背后,实际执行体却被 webpack 拆碎成几百个模块,每个模块只管一个细小功能,模块之间通过__webpack_require__互相加载。这种结构对逆向最大的杀伤力不在加密算法本身,而在代码组织方式——你很难用“搜索关键词→定位函数→读逻辑”的老办法一次搞定。
传统逆向的思路是直接找到生成签名的那个函数,然后复制出来独立运行。但在 webpack 打包的场景下,签名函数依赖的未必是明文可读的代码,可能是一串带混淆的模块 ID,甚至运行时才动态拼接。这也是为什么网上很多文章说“h5st 逆向要补环境”——其实补环境只是表象,真正的问题是模块加载体系没被还原,导致函数无法自洽运行。
这份代码的做法是顺着 webpack 的模块注册表逆向:先拿到自执行函数的外壳,再识别模块导出与依赖关系,最后把签名函数依赖的模块单独摘出来,在 Node 里构造一个 mini 版 webpack runtime。这样就不需要去抠每一个字节,而是把整个计算链路搬到自己手里。
2.2 识别 webpack 打包产物的五个特征
拿到压缩后的 JS 文件,先别急着搜 h5st 字符串,先判断它是不是 webpack 产物、属于哪种结构。我一般按这五个特征快速确认:
第一,文件开头或中部有(()=>{...})()形式的自执行函数,内部定义了一个__webpack_require__函数。第二,模块注册表是一个数组,push进去的是一个个函数,形如[function(module, exports, __webpack_require__){...}, ...]。第三,代码中存在__webpack_require__.d、__webpack_require__.o、__webpack_require__.r这类工具函数定义。第四,所有模块 ID 是数字序号,模块之间通过__webpack_require__(编号)跳转。第五,全局暴露点通常挂一个window属性,属性值是一个对象,内部方法才是真正业务入口。
识别这一步的价值在于决定后续策略:是直接整体调用 webpack runtime,还是局部抽取模块。整体调用的好处是不漏依赖,坏处是可能引入环境检测;局部抽取更干净,但需要手工标注模块边界。这份代码走的是局部抽取加 mini runtime 还原,原因在于 h5st 的计算模块相对独立,运行时环境检测的部分其实集中在入口壳子上,剥掉壳子反而更稳定。
// 识别 webpack 模块表的标准写法 const fs = require('fs'); const raw = fs.readFileSync('./h5st.js', 'utf-8'); // 提取自执行函数体的粗略方法(正则仅做初筛,严谨做法是括号匹配) const match = raw.match(/\(\(\)=>\{([\s\S]*?)\}\)\(\)/); if (match) { console.log('检测到 webpack 自执行函数壳,长度: ' + match[1].length); } // 检查是否存在模块注册表特征:webpackJsonp 或 __webpack_require__ 数组 const hasJsonp = raw.includes('webpackJsonp'); const hasRequire = raw.includes('__webpack_require__'); console.log({ hasJsonp, hasRequire });这段代码做的是拿到 JS 文件后的快速体检。includes判断是粗粒度,真正严谨的做法需要做括号配对提取函数体,但初筛已经够用。关键是确认文件确实是 webpack 产物,避免后续用错了工具链。
2.3 模块依赖关系梳理:从模块表到调用链
确认 webpack 结构后,下一件事是抽取模块依赖图。压缩后的模块注册表虽然不包含模块名,但函数内引用的模块 ID 是明确常量,分析这些常量就能得到依赖关系。常见做法是写一段脚本遍历所有模块函数体,正则提取__webpack_require__(数字)形式的调用,然后构建邻接表。
直白地说,这个过程相当于把“哪个模块依赖哪个模块”画成一张有向图。h5st 的算法核心模块往往不依赖太多外部环境,它的依赖链很集中——因为生成签名的计算必须在纯 JS 环境里完成,不可能依赖具体浏览器 API。
这份资源给出的完整代码里,模块抽取脚本做了两件事:一是自动扫描依赖链,二是把依赖链涉及的模块函数体提取出来拼装成新文件。拼装后的文件自带一个迷你 runtime,用__webpack_require__加载这些模块。整个过程在命令行里一步完成,不需要手动逐模块复制。
依赖关系梳理完毕后,就进入动态调试阶段——定位真正调用 h5st 的入口函数。这是整个逆向过程中信息密度最高的一步,因为要回答“这个签名到底在哪里被算出来”的终极问题。
3. 定位加密入口:JS 逆向中 Hook 与调用栈分析的实战操作
3.1 Hook 工具选型与关键断点设置
h5st 逆向有一个先天优势:它必须对外暴露生成签名的能力给业务 JS 调用,所以入口函数是固定的几个名字——sign、getSign、_genSign之类。用 Hook 工具拦截这些入口,就能拿到传入参数和返回值。常规选择是 Chrome DevTools 的 Snippets 写 Hook 脚本,或者直接用 Frida 在 WebView 场景下注入。
我通常先搜代码里的h5st关键字,找到它被赋值的那个全局对象名。比如常见形态是window.h5st = {...}或者_jd.h5st,然后对该对象的方法做运行时替换:
// Chrome DevTools 中 Hook h5st 签名入口 const originGetSign = window.h5st.getSign; window.h5st.getSign = function (...args) { console.log('[Hooked getSign] args:', args); const result = originGetSign.apply(this, args); console.log('[Hooked getSign] result:', result); return result; };这段代码的思路是“包装原函数而非替换实现”,好处是不会破坏原逻辑。apply保证了this指向不变,传入参数与返回值都被打印。执行完这一步,刷新页面触发一次真实请求,控制台就能看到 h5st 的入参——它通常是几个字符串、时间戳、一个加密的 body 参数,以及 appId 之类固定值。这些参数直接决定了后续算法还原时要处理哪些变量。
3.2 调用栈回溯定位核心算法模块
拿到入参后,继续在 Hook 函数里打印调用栈。console.trace()是基本功,但生产环境的压缩代码会让调用栈变成一堆数字 ID,看起来毫无头绪。这时就要结合上一章构建的模块依赖图,把堆栈里的模块 ID 映射成模块序号,再判断哪些模块是业务逻辑、哪些是算法逻辑。
实话说,调用栈回溯最花时间的不是找栈,而是区分“谁在调”和“谁在算”。业务模块会多次调用 getSign,入参大同小异;算法模块只在签名计算内部被调用一次或少量几次,入参带有明显特征——比如某个用来混淆的随机数、时间戳、密钥片段。遇到这种情况,我会在 Hook 里把入参逐一打印后,再对返回值做哈希对比,粗筛出哪些函数参与了签名生成。
这份资源的完整代码里,已经处理好了这一层筛选工作,直接看注释就能找到算法核心模块的位置。但筛出来的模块本身往往是一堆不可读的压缩代码,需要对它做还原优化,才能理解并稳定复算出结果。
3.3 参数拼装顺序确认与时间戳同步验证
h5st 生成过程有一个关键细节:时间戳是外部传入的,而不是函数内部自动取的。如果还原出的函数内部自带时间戳逻辑,会导致每次调用签名数值都不稳定,没法在 Node 端做对拍验证。所以定位入口函数后,要立刻确认参数拼装顺序——哪些参数在前、哪些在后、是否有时钟参与。
我习惯在 Hook 后动一个小手脚:把传给 getSign 的时间戳参数改成固定值,比如1700000000000,然后观察返回的签名是否变化。如果签名不变,说明时间戳只是透传但不参与算法计算;如果签名变了,说明它是算法入参的一部分。绝大多数情况是后者,因为 h5st 本身就是将时间戳纳入签名计算的——它的抗重放能力依赖这个值。
时间戳确认后,接下来就是纯技术活:把算法复刻到 Node.js 上,并且保证输出和浏览器端完全一致。这步的难点不在算法本身,而在 webpack 模块的搬移方式。
4. 用 webpack 方式进行算法还原:从抠代码到 Node.js 环境运行
4.1 模块摘取的最小集计算
前面提到了依赖关系梳理,这一步要落得更细。计算出“最小可运行模块集”不是简单的BFS,因为 webpack 模块之间可能通过__webpack_require__.e做动态加载——也就是按需加载的异步模块。h5st 的主流程不会异步加载模块,但它的依赖模块可能引用其它工具函数,而这些工具函数又不一定在同一个注册表里。
所以摘取模块的最小集时,我一般从入口模块出发做递归遍历,维护一个已访问集合,遍历到重复模块就终止。遇到动态加载(形如Promise.all([__webpack_require__.e(123)]))就单独打标记,后续在 Node 端手动补齐。完整资源代码里附带了一个extract.js,它就是做这个自动摘取工作的,跑完会生成h5st_core.js,这个文件不依赖浏览器环境,可以直接在 Node 中验证。
4.2 构建迷你 webpack runtime:关键函数说明
摘取出来的模块函数体,需要一套最简单的加载器才能运行。独立写完这套加载器也不算难,核心就两个函数:__webpack_require__负责按模块 ID 执行函数并缓存结果,__webpack_require__.d负责在导出对象上定义属性。但这份资源的代码里,runtime 恰好是现成抽出来的那一版——它连__webpack_require__.n这种冷门兼容函数都带了,说明作者是把原版 runtime 函数体完整剥出来的,而不是自己重写。
// 迷你 webpack runtime 核心代码(节选,已做注释精简) const modules = {}; // 模块 ID -> 函数体 const cache = {}; function __webpack_require__(moduleId) { if (cache[moduleId]) { return cache[moduleId].exports; } const module = { exports: {} }; modules[moduleId].call( module.exports, module, module.exports, __webpack_require__ ); cache[moduleId] = module; return module.exports; } // 调用 h5st 核心入口(模块号按实际分析结果填) const h5stCore = __webpack_require__('h5st_core_module_id'); const ts = Date.now(); const sign = h5stCore.sign({ appId: '你的appId', timestamp: ts, body: JSON.stringify({ orderId: 'test' }) }); console.log('生成的签名:', sign);这段代码展示的是还原后的调用方式。注意第四行module对象是每次独立创建的,这保证了多个模块各自维护独立的exports而不互相污染。cache的作用是避免重复执行模块——这在 webpack 里是标准行为,但在手动搬移时经常被忽略,导致模块内部状态被初始化两次。
参数说明:appId是业务标识,不同页面不同;timestamp是毫秒级时间戳,必须与请求头里的时间一致,否则服务端校验会拒绝;body是要被签名的数据,格式取决于业务场景。这三个参数也是浏览器端 Hook 时能拿到的原型入参。
4.3 浏览器与 Node 端签名一致性验证方法
算法还原是否成功,唯一标准是:同样的入参,浏览器生成的签名和 Node 生成的签名完全一致。这要求验证时做两个同步:时间戳同步——把浏览器端 Hook 到的时间戳硬编码进 Node 脚本;body 同步——请求体字符串必须完全一致,包括长度和编码。
我通常把验证写成比较脚本,一次跑两组:
const assert = require('assert'); const testCases = [ { appId: '11111', timestamp: '1700000000000', body: '{"page":1,"limit":10}', browserSign: '3ba9f1a2d5c8...' // 从浏览器 Hook 输出里粘贴 }, { appId: '22222', timestamp: '1700000000001', body: '{"keyword":"手机"}', browserSign: '7c4d0e9a8b3f...' } ]; for (const tc of testCases) { const nodeSign = h5stCore.sign({ appId: tc.appId, timestamp: tc.timestamp, body: tc.body }); assert.strictEqual(nodeSign, tc.browserSign); console.log(`用例通过: ${tc.appId} -> ${nodeSign}`); }这段脚本的意义在于:把浏览器端和 Node 端的输出做硬性比对,不通过就说明核心模块摘取不完整或 runtime 有差异。我第一次跑的时候第一组用例直接 mismatch,排查后发现是 body 字符串里的空白符被浏览器做了 trim。这类细节最容易翻车,也正是接下来要说的核心避坑点。
5. 避坑与排查:h5st 逆向里最容易翻车的关键细节
5.1 环境检测与window对象缺失导致的报错
现象:从浏览器把模块搬到 Node.js 后,运行时报window is not defined或navigator is not defined。
原因:h5st 的部分工具模块(尤其是 UA 或设备指纹相关的函数)在加载阶段就直接引用了浏览器全局对象,即使业务逻辑不调用它,模块初始化时也会触发访问。这是 webpack 模块搬移最常见的坑——你以为抠的是核心算法,实际连带引入了一堆环境依赖。
解决:在 Node 脚本开头补一个最小化全局环境,把不用的字段置空,存在即可:
global.window = global.window || {}; global.navigator = global.navigator || { userAgent: 'Mozilla/5.0' }; global.document = global.document || {};注意navigator.userAgent的值会影响指纹类参数的计算结果,如果 h5st 算法里取了 UA 参与编码,这个值不能随意填,要从浏览器端真实复制一份。这是很多人在验证阶段百思不得其解的“为什么浏览器算的和 Node 算的不一样”的隐藏根因。
5.2 时间戳参与签名导致的不可复现
现象:Node 端跑出的签名每次都不一样,比对时提示 mismatch。
原因:还原后的代码里保留了内部自动取时间戳的逻辑,导致传入的 timestamp 参数被忽略,或者与内部时间戳做了某种组合运算。浏览器端 Hook 时因为运行时间靠近,看起来参数和结果匹配,一旦在 Node 端手动传入不同 timestamp,签名就全乱了。
解决:在模块代码中搜索Date.now()和new Date().getTime(),找到后将它们替换为传入参数。稳妥的做法是给 runtime 挂一个特殊的__timeProvider,统一接管时间请求:
function timeProvider() { return global.__forceTimestamp || Date.now(); } // 将原来代码里的 Date.now() 全部替换为 timeProvider()用法是:浏览器端 Hook 到一组入参后,把 timestamp 硬编码赋值给global.__forceTimestamp,然后跑 Node 端签名,确保与浏览器一致。验证通过后删除该赋值,恢复正常调用。
5.3 webpack 异步加载模块导致的 undefined 函数调用
现象:搬移后的代码提示Cannot read property 'call' of undefined,且错误栈只给了模块 ID 没有函数名。
原因:h5st 的主算法流程里不涉及异步模块,但某个依赖模块的初始化代码里可能有 optional chaining 或条件加载逻辑——它尝试通过__webpack_require__.e加载一个按需模块,这个模块没有被摘取进来。
解决:在摘取脚本的输出里检查是否有__webpack_require__.e调用。如果有,直接搜索该模块 ID 并手动加入 modules 列表。如果无,说明是条件分支执行到了未加载分支,替换__webpack_require__.e为一个返回 rejected Promise 的函数,确保错误能立即暴露而不是静默失败:
__webpack_require__.e = (moduleId) => { return Promise.reject(new Error(`Unhandled async module: ${moduleId}`)); };注意这里不是真正解决异步加载,而是让错误显性化,快速定位到底哪个分支被触发了。等找到以后补齐模块,再把这个覆盖函数移出。
5.4 请求体序列化差异导致的签名 mismatch
现象:同样的入参、同样的时间戳,浏览器与 Node 结果不一致。
原因:body 参数在不同环境被序列化的方式不同。浏览器端业务逻辑可能对对象做了 JSON.stringify,而 Node 端脚本直接传的字符串;或者浏览器端做了 key 排序后拼接,Node 端没做。签名是对字节敏感的,任何一个空格或中文引号的差异都会导致算法结果不同。
解决:控制变量迭代验证——先不传 body,只传一个空对象,观察两边签名是否一致;再依次加字段,每次加一层依赖,直到定位哪个字段序列化方式不同。这是纯手动排查,但效率很高,因为它把问题压缩到了“每个字段逐一过”的粒度。
6. 进阶验证与工程化:把 h5st 还原代码接到你的采集链路里
6.1 用日志驱动的方式记录每次签名请求的上下文
还原成功后,代码只是第一步,更实际的问题是工程接入。我习惯在 Node 端做一个日志中间层,把每次生成的签名、入参、时间戳、结果全部落盘,方便回溯和排查线上问题:
const logger = { log(signContext) { const logLine = [ signContext.timestamp, signContext.appId, signContext.body, signContext.sign ].join(' || '); fs.appendFileSync('./h5st_sign.log', logLine + '\n'); } }; // 每次调用后写日志 const sign = h5stCore.sign(ctx); logger.log({ ...ctx, sign });这个中间层最大的用途是快速对账:如果某天采集数据被判风控,把当时所有入参和签名拿出来,对比服务端返回的 header 或 JSON 里的校验信息,就能判断是签名算法随时间变化了,还是某个参数拼接方式变了。它也是长期维护逆向代码的必要基础设施。
6.2 签名算法变更后的快速适配流程
h5st 算法不是一成不变的。某东会不定期调整参数拼接规则或混入新的时间因子,表现是突然之间原本能通过的签名开始被拒。遇到这种情况不用慌,按下面的顺序排查:
第一步,重新在浏览器 Hook 入口函数,获取新的时间戳参数和 body 格式,不必看算法内部。第二步,将新参数喂给已有的 Node 端函数,观察输出格式是否与浏览器端一致。第三步,若不一致,用上一章 5.3 的技巧——把异步加载错误显性化,看有没有新增模块被调用。第四步,进入新增模块做代码比较,把差异点记录为 patch。
这个方法之所以可行,是因为 webpack 结构决定了算法更新本质上就是模块内容替换或函数逻辑替换,很少推翻整个模块体系。只要 runtime 还在,定位速度远比首次逆向快。
6.3 调用频率控制与风控体系规避的原则
不夸张地说,很多刚拿到完整代码的人第一步就跑全量数据,然后一小时内账号被风控。这里存在一个基本事实:h5st 反爬计算能力再强,也要配合业务风控体系共同起作用,签名通过了不代表请求合法。控制请求频率和业务轨迹的随机性,甚至比签名本身更是底线。
我给自己定了一个保守的基准线:单账号单 IP 下,每分钟不超过 15 个带签名的业务请求;每次采集任务前先模拟一次浏览器行为路径——先请求首页拿 cookie,再逐步进入列表页和详情页,而不是直接怼接口。cookie 和签名的配合也需要同时传给 Node 端请求函数,它们在某个分支里会参与签名计算,缺了就会导致服务端拿到签名却拒绝响应。
这些都不是代码层面的技巧,而是工程习惯。从那以后,我每次在群里看到有人抱怨“h5st 跑通了还是拿不到数据”,第一反应都是先问他单小时请求量多少,得到的答案几乎都是同一个。流量控制住,再把签名做对,链路就能稳定跑。希望帮到你。
本文还有配套的精品资源,点击获取