ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

逆向webpack打包的h5st签名参数:从模块图到运行时上下文

逆向webpack打包的h5st签名参数:从模块图到运行时上下文

简介:这份资源面向具备一定前端基础、希望深入理解移动端加密参数生成机制的爬虫学习者,聚焦某东平台以 webpack 方式打包的 h5st 逆向分析场景,提供一套可对照复现的完整代码案例。压缩包共 2 个文件,以 Python 与 JavaScript 源码为主,分别承担请求逻辑与前端加密逻辑的还原,整体约 159KB,体量轻便,便于快速阅读与本地调试。目前已有 858 人学习下载,说明该方向在爬虫实战中关注度较高。读者可从中获得 webpack 模块加载与调用链路的分析思路、h5st 参数生成过程的代码级还原,以及前后端脚本如何配合完成一次完整请求的参考实现,适合作为理解加密参数构造、提升逆向调试能力的实战素材,也便于在此基础上做二次分析与排错验证。

1. 签名参数不是玄学:h5st 到底在防什么

打开某电商 H5 页面的网络面板,你会看到每个关键接口都挂着一个叫h5st的参数,长度动辄几百字符,肉眼完全不可读。很多人第一反应是「加密了,得找解密函数」,于是顺着调用栈一路翻,翻到一堆混淆过的 JS 就卡住了。这里有个反直觉的结论:h5st 的核心难点从来不是「加密算法有多强」,而是「它把哪些运行时状态揉进了签名里」——时间戳、页面指纹、请求体摘要、随机数、版本号,这些东西按特定顺序拼接后再做哈希,任何一项对不上,服务端直接判定签名无效。你就算把哈希函数抄下来,缺了运行时上下文照样算不出能过校验的值。

这也是为什么 webpack 打包的 H5 站点让逆向的人格外头疼。webpack 会把模块拆成 chunk,用__webpack_require__做运行时加载,函数名被压缩成单字母,字符串常量被抽到独立的数组里做偏移索引。你看到的不是一段线性的加密逻辑,而是一张模块依赖图。想搞懂 h5st 的生成机制,本质上是想搞懂「这张图里哪个模块负责拼签名、它从哪些模块取运行时数据、这些数据在什么时机被写入」。搞清这条链路,你才能理解为什么单纯复制一个函数跑不通,也才能判断哪些参数可以固定、哪些必须实时采集。对做爬虫的人来说,这是一道绕不开的坎,踩过一次就明白:签名参数不是黑匣子,它是一份被刻意打散的运行时快照。

2. webpack 运行时:模块图为什么让逆向翻车

2.1 从__webpack_require__看模块加载的真实顺序

webpack 打包产物最外层通常是一个 IIFE,内部维护一个modules对象和一个installedModules缓存。所有业务代码都被包成function(module, exports, __webpack_require__)的形式塞进modules。页面初始化时,入口模块被__webpack_require__(entryId)拉起,之后每个模块第一次被引用时才执行,执行结果缓存起来。这个「懒执行 + 缓存」的机制意味着:签名模块不一定在页面加载时就跑,它可能在你触发某个请求时才被 require 进来。

常见的实现思路是,签名逻辑会被单独打成一个 chunk,通过__webpack_require__.e(chunkId)动态加载。你在 DevTools 里看到的调用栈,往往是从业务模块跳到__webpack_require__.e,再跳到 chunk 里的签名函数。如果只盯着最上层那个函数看,会误以为签名是同步算出来的,实际上它前面还有一段异步加载。理解这一点很关键:断点要下在 chunk 加载完成之后,否则你拿到的__webpack_require__缓存里根本没有签名模块。

// webpack 运行时简化结构,用于理解模块注册与加载 (function(modules) { var installedModules = {}; function __webpack_require__(moduleId) { if (installedModules[moduleId]) { return installedModules[moduleId].exports; // 命中缓存直接返回 } var module = installedModules[moduleId] = { i: moduleId, l: false, exports: {} }; modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); module.l = true; return module.exports; } // 动态 chunk 加载,返回 Promise __webpack_require__.e = function(chunkId) { return new Promise(function(resolve) { // 实际会插入 script 标签加载对应 chunk 文件 resolve(); }); }; __webpack_require__(0); // 启动入口模块 })({ 0: function(module, exports, __webpack_require__) { /* 入口 */ }, 1: function(module, exports, __webpack_require__) { /* 签名模块 */ } });

上面这段是 webpack 运行时的骨架,installedModules是缓存表,__webpack_require__.e负责异步拉 chunk。参数moduleId是打包时分配的整数,chunkId对应拆分出来的文件编号。实际产物里这些名字会被压缩,但结构不变。逆向时我一般会先在控制台把__webpack_require__.c(缓存对象)打印出来,看哪些模块已经被实例化,再顺着exports找签名相关的函数名。这一步能省掉大量在混淆代码里瞎翻的时间。

2.2 字符串数组与偏移索引:常量为什么搜不到

webpack 配合压缩工具后,会把代码里所有字符串常量抽到一个大数组里,原位置替换成_0x1a2b[3]这样的索引访问。你搜"h5st"搜不到,搜"sign"也搜不到,因为它们在数组里躺着,访问时靠下标。常见的实现思路是,这个数组还会做一次「旋转」——启动时执行一段代码把数组元素循环移位,让静态分析时看到的索引和运行时实际取到的值对不上。这就是为什么直接读源码里的_0x1a2b[3]得到的字符串可能是错的。

破解这个的手段不复杂:在数组定义之后、旋转逻辑执行之后下断点,把数组整个 dump 出来,再写个小脚本把索引映射还原。我一般会在控制台执行类似JSON.stringify(_0x1a2b)把数组存下来,然后对照代码里的索引逐个替换。这一步做完,混淆代码的可读性会提升一大截,签名逻辑里那些关键字段名、拼接分隔符、哈希前的固定盐值就都浮出来了。血泪经验是:别急着读逻辑,先把常量表还原,否则你会在错误的字符串上浪费半天。

2.3 运行时上下文:签名依赖的页面状态从哪来

h5st 这类签名参数,除了算法本身,还依赖一批运行时采集的值。常见的包括:当前时间戳(毫秒级)、一个随机数、页面 URL 的某些片段、浏览器指纹(UA、屏幕尺寸、时区等)、请求体的哈希摘要、以及一个版本标识。这些值在签名函数执行时被读取,拼成一个原始串,再做哈希。问题在于,这些值的采集点分散在不同模块里,有的在页面初始化时写入全局对象,有的在发请求前临时计算。

理解这条链路的方法是:在签名函数入口下断点,看它从哪些变量取值,再反查这些变量在哪赋值。常见的实现思路是,站点会维护一个全局的window.__INITIAL_STATE__或类似的运行时对象,签名模块从里面读版本号和指纹,时间戳和随机数则现场生成。请求体摘要需要你传入和实际发送一致的 body,这一点最容易翻车——很多人签名算对了,但 body 序列化方式和服务端预期不一致,摘要对不上,照样被拒。所以逆向 h5st 不是找到一个函数就完事,而是要把「哪些值固定、哪些值实时算、body 怎么序列化」这三件事对齐。

3. h5st 生成链路:从参数拼接到哈希输出

3.1 原始串的拼接顺序与分隔符

签名参数的可读部分通常是明文拼接的,比如时间戳;随机数;版本号;指纹;body摘要,用分号或竖线分隔,然后整体做一次哈希,再把哈希结果和明文按某种格式组合成最终的h5st值。拼接顺序是硬约定,服务端按同样顺序重算,顺序错一位就失败。常见的实现思路是,明文部分会保留在最终参数里(方便服务端取用),哈希部分附在后面做校验。所以你看到的长字符串,前半段其实是可读的,只是被编码过。

逆向时我一般会先把最终参数按分隔符拆开,观察每一段的长度和字符集,判断哪段是时间戳、哪段是随机数、哪段是哈希。时间戳是 13 位数字,随机数长度固定,哈希段通常是十六进制或 Base64。拆清楚结构,再回头找拼接代码,定位速度会快很多。这里的关键认知是:h5st 不是单一加密输出,它是「明文载荷 + 校验摘要」的组合体,理解这个结构比理解哈希算法本身更重要。

3.2 哈希函数的选型与调用方式

哈希部分常见的选型是 MD5 或 SHA 系列,具体用哪个要看站点实现。webpack 产物里这类哈希函数往往以独立模块存在,输入是拼接好的原始串,输出是十六进制字符串。调用方式上,有的站点直接调库,有的自己实现了一份精简版。自己实现的版本反而好办,逻辑直白;调库的版本需要确认库的版本和参数(比如是否加盐、编码方式)。

// 签名链路示意:拼接 -> 哈希 -> 组合,非真实站点代码 function buildSign(ctx) { var ts = Date.now(); // 毫秒时间戳,实时生成 var nonce = Math.random().toString(36).slice(2, 10); // 随机串 var ver = ctx.version; // 从运行时对象读取 var fp = ctx.fingerprint; // 页面指纹,初始化时写入 var bodyHash = md5(JSON.stringify(ctx.body)); // 请求体摘要,序列化方式要对齐 var raw = [ts, nonce, ver, fp, bodyHash].join(';'); // 拼接顺序是硬约定 var sign = md5(raw); // 哈希输出 return raw + ';' + sign; // 明文 + 摘要组合 }

这段示意代码把链路讲清楚了:ts和nonce现场生成,ver和fp来自运行时上下文,bodyHash依赖请求体的序列化结果,raw按固定顺序拼接,最后哈希并组合。参数上,ctx.body必须和实际发送的 body 完全一致,JSON.stringify的键顺序、空格处理都会影响摘要。实际站点里这些函数名是混淆的,但数据流就是这个形状。定位到这段逻辑后,剩下的工作是把ctx里每个字段的来源摸清,确认哪些能固定、哪些必须每次重算。

3.3 版本号与指纹:为什么固定值会失效

版本号看起来是个常量,但它往往和站点前端发版绑定,服务端会校验版本号是否在允许范围内。你把某次抓到的版本号写死,短期内能用,站点一发版就失效。指纹同理,它可能包含 UA、屏幕分辨率、时区、语言等,服务端不一定逐项校验,但某些字段变了会触发风控。常见的实现思路是,指纹采集逻辑会在页面加载时执行一次,结果存进运行时对象,签名时直接读。

这里的设计取舍值得琢磨:站点为什么要把版本号和指纹揉进签名?因为这两项能有效区分「真实浏览器里的正常请求」和「脚本批量构造的请求」。版本号保证你用的是当前前端逻辑,指纹保证请求来自一个有状态的页面环境。对逆向的人来说,这意味着纯算法层面的复现不够,还得模拟出一个合理的运行时上下文。我的做法是把指纹采集逻辑单独抽出来跑一遍,拿到和真实页面一致的值,再喂给签名函数,而不是拍脑袋填默认值。

4. 避坑与排查:签名算对了为什么还是被拒

4.1 现象:本地算出的 h5st 长度对但服务端返回校验失败

原因通常是拼接顺序或分隔符和服务端预期不一致。混淆代码里分隔符可能是从字符串数组取的,你还原常量表时漏了一项,拼出来的原始串结构就错了。解决方法是把最终参数按分隔符拆开,逐段比对长度和字符集,再对照还原后的常量表确认分隔符。我一般会在签名函数返回前打印raw,和抓包得到的明文段做逐字符对比,差异一眼就能看出来。

4.2 现象:时间戳和随机数都实时生成了,仍然偶发失败

原因多半是请求体摘要不一致。JSON.stringify对键顺序敏感,你构造的对象键顺序和页面实际发送的不一样,摘要就不同。另外有些站点会对 body 做额外处理(比如去掉空值、统一编码),你没跟上就会翻车。解决办法是直接抓取页面实际发送的 body 原文,用它算摘要,而不是自己重新序列化。这一步的教训是:签名链路里任何一处「我以为一样」,都可能是失败点。

4.3 现象:断点打上了但签名函数不执行

原因是签名模块是异步 chunk 加载的,断点下早了,模块还没被 require 进来。解决办法是在__webpack_require__.e或 chunk 加载完成的回调里下断点,等模块实例化后再跟进签名函数。另一个可能是签名逻辑被放在了 Worker 里,主线程断点抓不到,需要在 Worker 的源码面板里单独下断点。

4.4 现象:常量表还原后索引仍然对不上

原因是数组做了运行时旋转,你 dump 的时机在旋转之前或之后不一致。解决办法是在旋转逻辑执行完毕后立即 dump,或者在控制台直接读取运行时数组的当前状态,而不是读源码里的初始定义。确认方法是随便取一个索引,用运行时数组取值和代码里预期值比对,一致了再批量还原。

4.5 现象:指纹字段填了默认值,请求被风控拦截

原因是服务端对指纹做了合理性校验,UA 和屏幕尺寸、时区不匹配会触发异常判定。解决办法是把指纹采集逻辑完整跑一遍,拿到和真实浏览器环境自洽的一组值,而不是东拼西凑。这里没有捷径,指纹的各项之间有关联性,单独改一项反而更容易暴露。

5. 设计取舍:为什么这类签名机制值得从运行时层面理解

把 h5st 拆到这个程度,会发现它的防护思路并不神秘:用 webpack 打散代码结构,用字符串数组和旋转抬高静态分析成本,用运行时上下文把签名和页面状态绑定,用版本号制造时效性。这套组合的取舍很清晰——它不追求密码学意义上的不可破解,而是追求「让批量复现的成本高于收益」。哈希算法是公开的,拼接规则一旦被还原也不难复刻,真正起作用的是那些必须实时采集的运行时值。

这也解释了为什么单纯抄一个签名函数往往跑不通。函数只是链路的一环,它依赖的上下文分散在模块图各处,依赖的 body 序列化方式藏在业务代码里,依赖的版本号随发版变化。理解这一点,逆向的重心就从「找算法」转向「还原数据流」:先 dump 常量表,再定位签名模块,再顺着变量反查采集点,最后把整条链路在本地复现并和抓包结果对齐。这个顺序比盲目读混淆代码高效得多。

验证方法上,我习惯做两件事:一是把本地算出的raw明文段和抓包得到的明文段逐字符比对,确认拼接结构一致;二是用同一组输入多次调用签名函数,观察哪些段变化、哪些段稳定,变化的段就是实时生成的,稳定的段就是上下文读取的。这两步做完,链路基本就摸清了。至于指纹和版本号这类会随环境变化的字段,我的做法是保留采集逻辑而不是写死值,宁可多跑一步采集,也不留一个随时会失效的常量。

从那以后我每次碰这类 webpack 打包的签名参数,都强制先 dump 常量表、再打印模块缓存、最后才读逻辑,顺序不乱。这套习惯帮我省下了大量在混淆代码里打转的时间,也让我少踩了几次「算法抄对了但上下文错了」的坑。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进