webpack H5ST逆向:从模块定位到签名复现
发布时间:2026/11/6 6:51:28 锦皓数字建站

简介面向爬虫与前端逆向学习者的实战代码包围绕某东平台 webpack 方式加载的 h5st 参数展开重点解决加密参数定位、模块拆分分析与还原思路等问题适合具备 JavaScript 基础、希望理解前端打包与逆向调试流程的读者。压缩包共 2 个文件包含 1 个 Python 脚本和 1 个 JavaScript 文件整体约 159KB分别对应请求侧调用与前端逻辑分析便于对照阅读。已有 860 人学习说明该主题在爬虫参数分析与 webpack 模块追踪场景中具有一定关注度。代码保留了较完整的分析链条可帮助读者梳理 h5st 生成逻辑、观察模块加载关系并在此基础上练习断点调试、请求参数核对与代码还原积累前端逆向与爬虫数据获取的排错经验。1. 一个反直觉的结论搜关键词是最后一步我第一次处理某电商平台搜索接口的签名参数时花了整整两个小时在 Sources 面板里全局搜索 webpack 产物中的 h5st命中的位置全是a.b.c这种被压缩成一两个字符的引用点点进去是一屏四千行的打包结果。后来才想明白webpack 打包的站点搜到的每一个字符串都只是「被引用」真正的生成逻辑藏在运行时才组装起来的模块表里。所以顺序得反过来——先找到模块加载器再找到目标模块最后才去搜字符串。这篇内容按这个顺序把 webpack 方式的 H5ST 逆向从定位到复现完整走一遍新手能照着步骤把参数跑出来熟手能看到每一步为什么这么选、失败时该看哪里。爬虫写到需要过签名的阶段决定进度的往往不是算法本身而是定位效率。2. 摸清对手H5ST 的字段构成与 webpack 留下的口子2.1 从一次请求里能读出的四类线索抓一次搜索列表请求把 query string 摊开看H5ST 这类签名参数通常是一长串用分号或者特定分隔符切开的字段。不同时期段数和顺序会调整但结构上一般逃不出这几类一段毫秒或秒级时间戳、一段随机串或者从本地存储取来的固定值、一段标识应用或接口的编码、一段版本号、最后一段才是真正的签名值。判断哪一段是什么不必靠猜把同一接口连续抓三次包对比就能定下来每次都变的只有时间戳和随机串跟着接口名走的那段不变最后一段长度固定且字符集像 hex 或者 base64。第二类线索是生成时机。有些站点的签名在页面加载完成时就一次性算好塞进全局变量这种最好办直接读变量就行更多站点的签名是在点击搜索、翻页、切排序的瞬间现算的这意味着它依赖实时输入你不能指望在控制台里直接读到一个现成值。区分方法很简单在控制台刷新页面后立刻打印相关全局变量如果值是 undefined 或者上一次的旧值就是现算型。第三类是服务端校验的严格程度这直接决定你还原到什么精度才算过关。把抓到的请求原样重放一次再把时间戳改掉一小时重放一次观察返回。常见结果是签名错误返回一个固定错误码时间戳过期返回另一个错误码如果改时间戳也照常返回数据说明服务端对时效校验很松你的还原压力会小很多。第四类是它跟哪些请求参数绑定。把 query 里除了签名以外的参数改一个值、签名不动再发一次如果报签名错误说明签名值里包含了对业务参数的摘要那么还原时参数的排序、空值处理、编码方式必须和前端一字不差。这一步先做完后面才不会在细节上反复返工。2.2 webpack 运行时留下的三个抓手webpack 打包后的产物有一个非常稳定的特征模块不会直接执行而是先注册进一张表再由加载器按需调用。webpack 4 时代这张表通过webpackJsonp数组的 push 注进去webpack 5 换成了self.webpackChunk加项目名名字变了机制没变。模块表的键可能是相对路径也可能是数字 id值永远是一个三参数函数第三个参数就是那个精简版的加载器。// webpack 4 的典型产物结构模块表在运行时由 push 注入 (window.webpackJsonp window.webpackJsonp || []).push([ [chunk-search], // chunk id跟模块表本身无关 { // 键是模块路径值是一个 function(module, exports, __webpack_require__) ./src/utils/sign.js: function (module, exports, n) { // n 就是精简后的加载器名字被压成了一个字符 var r n(./src/utils/crypto.js); // 依赖另一个模块 module.exports { sign: function (t) { return r.h(t); } }; }, ./src/utils/crypto.js: function (module, exports, n) { // 真正的哈希/加密实现通常落在这种底层模块里 } } ]);三个抓手就藏在这段结构里。第一个是模块表本身拿到它就等于拿到了全部源码索引哪个模块负责签名一眼能看出来。第二个是加载器函数n它内部一定有缓存字典常被压缩成c或i和模块表引用常被压缩成m在这两个属性上做文章可以在运行时把任意模块的导出对象捞出来。第三个是模块间的 require 关系顺着n(...)的调用链往下走能画出目标模块的依赖树后面抠代码时按这棵树补桩一个都不会漏。有一类容易被忽略的情况签名逻辑被拆到了异步 chunk 里主包里只有一句动态 import。这种在 Network 面板里能看到一个额外的小 js 文件加载时机往往就在你点击搜索的前后。抓包时把「保留日志」打开别把这类延迟加载的 chunk 漏掉否则你会在主包里找到一堆没用的空壳。3. 定位入口三种打法从请求参数倒推到签名函数3.1 断点打法让调用栈自己把答案交出来三种打法里断点最稳、最不依赖运气也最推荐新手先用。打开开发者工具的 Sources 面板在右侧断点区找到 XHR/fetch Breakpoints勾选「任意 XHR」或者填上目标接口的路径片段。这里有个细节决定成败默认触发时机是请求已经发出此时参数早就拼好了你只能看到一个结果值把它改成在发送之前触发栈帧里才留着拼装过程的现场。断住之后不要急着看当前函数直接翻调用栈从下往上数五到十五层。签名相关的函数通常有这么几个特征函数体里出现大量字符串拼接和;分隔、出现localStorage.getItem或sessionStorage.getItem、出现Date.now()或者new Date().getTime()、末尾调用了某个哈希或加密函数。找到这一层之后在它的入口处打一个条件断点条件写成参数名加判定比如arguments.length 0 typeof arguments[0] object避免被页面上其他无关调用频繁打断。断点打稳之后把这一帧局部变量里的业务参数、时间戳、中间结果全部记下来。这一步的产出物是一张对照表输入是什么、经过哪几步变换、最终输出的每一段对应哪一步。这张表比任何反编译结果都可靠因为它是运行时真实值不是读代码猜出来的。还有一种更省事的变体适合已经在页面里定位到全局函数的情况在控制台里 hook 掉XMLHttpRequest.prototype.setRequestHeader或者window.fetch把每次请求的 header 和 body 打印出来同时用console.trace()把调用栈也打出来。这样你不用一直盯着断点滚动页面、点击搜索调用栈就自动堆在控制台里慢慢看就行。缺点是栈信息会被压缩得比较难看需要配合 sourcemap如果有的话或者格式化按钮。3.2 搜索打法用特征串和条件断点缩小包围圈搜索打法见效快翻车也快关键在搜什么。搜h5st这类参数名命中的十有八九是拼接语句不是生成逻辑但拼接语句本身也是好线索——它周围往往就是完整组装过程。搜业务接口路径/api/search能落到发起请求的那一层。真正高效的搜索词是那些不会随版本变化而消失的底层特征加密库里的固定常量初始向量、盐值、S 盒、fromCharCode、charCodeAt密集出现的循环、toString(16)补零逻辑。全局搜索CtrlShiftF和当前文件内搜索要分开用。全局搜索适合定大方向但结果列表里混着大量打包产物和第三方库需要按文件大小和路径筛。打过断点定位到具体文件之后改用当前文件搜索找细节效率高很多。格式化后的代码动辄上万行靠滚动是看不过来的这时候要用条件断点做二分。做法是在几个可疑函数上都打上断点条件统一写成「入参包含某个特征字段」哪个先停就说明哪条路径是对的然后把其他断点关掉。这招在模块被拆得很散的情况下特别好用本质上是拿运行时当搜索引擎。提示断点打在压缩代码上时先把 Pretty print 打开再设条件否则行号和列号对不上条件断点会失效或者频繁误触发。3.3 抠模块打法把目标模块从模块表里单拎出来定位到具体模块之后最省力的验证方式是直接在控制台里调它而不是急着把代码复制出来。前提是先拿到那个加载器在任意一个模块函数体的第一行打上断点刷新页面触发它执行此时作用域里的第三个形参就是加载器。把它挂到全局比如window.__wpRequire n后面的操作就都在控制台完成了。// 断点命中后先把加载器挂到全局 window.__wpRequire n; // 然后按模块路径直接取导出对象不用管它依赖谁 var mod window.__wpRequire(./src/utils/sign.js); console.log(Object.keys(mod)); // 看暴露了哪些方法 // 拿一组真实入参试跑结果应该和抓包值的前几段对得上 var out mod.sign({ keyword: 测试, page: 1, pageSize: 20 }); console.log(out);参数说明__wpRequire是调试期临时挂载页面刷新就没了别指望持久化mod的键名经过压缩Object.keys出来可能只有一两个字母要看具体的typeof才知道是函数还是对象sign这个方法名是假设的实际可能是a、b、default逐个试一遍即可。这一步的产出是确认目标方法、确认入参形状、确认返回值的分段结构。三件事都清楚了再从浏览器里往外搬代码心里才有底。直接把整个 chunk 复制到本地跑通常的结果是缺依赖、缺全局变量报一堆 undefined反而拖慢节奏。真正搬代码时有个技巧不要复制整个模块只复制目标函数和它直接依赖的几个工具函数。复制完之后在浏览器里用Function.prototype.toString打印出来对照确保字符级一致尤其是那些带正则和位运算的片段肉眼看起来一样、实际差一个转义符就够你查半天。4. 搬到本地把签名模块还原成可跑代码的补桩与改写4.1 浏览器环境桩哪些必须补哪些可以糊签名代码一旦离开浏览器第一件事就是崩在各种全局对象上。补桩的原则是「按报错顺序补不预判」。常见的第一波报错来自window本身很多打包产物开头就是(window.webpackJsonp ...)Node 里没有 window 直接 ReferenceError。最粗暴也最有效的办法是把沙箱对象自己指向自己让window.window window成立绝大多数读取全局属性的代码就活了。// 在 Node 里跑签名模块之前先补一层最小环境 const vm require(vm); const sandbox { navigator: { userAgent: 抓包时那条请求的完整 UA, // 必须和浏览器一致UA 常被参与摘要 platform: Win32, language: zh-CN }, document: { cookie: }, // 需要 cookie 参与签名时把真实值填进来 localStorage: { // 用固定字典代替真实存储取值稳定才可复现 _d: { _k: 抓包里看到的值 }, getItem(k) { return this._d[k] || null; }, setItem(k, v) { this._d[k] v; } }, screen: { width: 1920, height: 1080 }, Date, Math, JSON, encodeURIComponent // 这几个直接用宿主原生 }; sandbox.window sandbox; // 让 window.window window 成立 sandbox.self sandbox; sandbox.globalThis sandbox; const ctx vm.createContext(sandbox); vm.runInContext(moduleCode, ctx); // moduleCode 是抠出来的模块源码参数说明navigator.userAgent必须是抓包那次请求的真实 UA长度、顺序、大小写都不能改很多站点会把 UA 全串进摘要localStorage用固定字典而不是空实现是因为不少签名会从里面取一个设备标识类字段返回 null 会让结果直接崩掉screen尺寸同理属于设备指纹的一部分值不对签名就对不上值对了也不代表一定对还需要和后面章节的比对方法配合验证。补完第一层大概率还有第二波报错集中在document.createElement(canvas)这类指纹采集上。这类逻辑往往还会调getContext(webgl)返回一堆浏览器渲染细节。这块是整个还原过程里最玄学的部分——你不用真的去实现一个 canvas只需要让这些调用返回固定值然后把返回值也跟着固定下来。判断标准只有一个你在本地跑出来的签名和浏览器里跑出来的签名逐字符相等那这个桩就是对的哪怕它返回的是一堆假数据。还有一类隐蔽的坑代码里调用了new Date()而不是Date.now()或者反过来。前者返回对象后者返回数字混用会导致时间戳位数不同。补桩时把Date整个透传进去然后在调用前用Date.now () 固定值覆盖掉这样两种写法都拿到同一个值便于比对。4.2 算法部分的拆解与改写签名算法本身通常不复杂复杂的是它前面的拼接规则。常见组合是业务参数按 key 排序拼成kvkv尾部追加时间戳和随机串整体做一次哈希或 HMAC最后转 hex 或者 base64再按约定格式和其他字段用分隔符连起来。改写时最容易出错的是三类细节排序规则是不是严格字典序大小写敏感与否、空值处理null、空串、undefined 参与不参与拼接、编码方式有没有先做一次encodeURIComponent。import hashlib import hmac def build_sign(params: dict, ts: str, nonce: str, secret: str) - str: # 1. 按 key 排序拼串过滤规则要和 JS 侧一致None 跳过、空串保留 plain .join( f{k}{params[k]} for k in sorted(params) if params[k] is not None ) # 2. 尾部拼时间戳和随机串分隔符和顺序错一个字符结果就全错 raw f{plain}ts{ts}nonce{nonce} # 3. HMAC-SHA256 转小写 hex注意 JS 侧可能是大写或者截取前 32 位 return hmac.new(secret.encode(), raw.encode(), hashlib.sha256).hexdigest()参数说明sorted(params)是 Python 的字典序如果 JS 侧用的是Object.keys().sort()两者对纯 ASCII 键的结果一致但一旦键里出现数字或中文就会分叉这种情况要手动实现比较函数secret是从代码里抠出来的固定盐值长度通常是 16 或 32 位hexdigest()返回小写如果浏览器里是大写加一个.upper()就行但别想当然一定要比对过再决定。如果哈希之后还有一步加密多半是 AES-CBC 加固定 IV或者 RSA 加密一段对称密钥。这类逻辑不要手写实现直接用成熟库把抠出来的 key、iv、padding 模式对着代码抄准。padding 模式是最容易抄错的参数PKCS7 和 ZeroPadding 在明文长度刚好是块大小整数倍时结果不同而这种边界情况恰恰最难在正常请求里遇到。4.3 比对验证怎么确认你还原的是对的验证的核心思路是「固定变量逐段比对」。先把时间戳和随机串这两个会变的输入固定成常量在浏览器里调一次目标函数拿到基准值再在本地用同样输入跑一次。两边的输出先比长度长度不一致说明少了字段或者编码方式不同长度一致就逐字符比第一个分叉的位置往往能直接指出是哪一段出了问题。def diff(browser_val: str, local_val: str) - None: if len(browser_val) ! len(local_val): print(f长度不一致: 浏览器 {len(browser_val)} / 本地 {len(local_val)}) return for i, (a, b) in enumerate(zip(browser_val, local_val)): if a ! b: # 打印分叉点前后各 8 个字符方便判断是哪一段字段开始出错 print(f第 {i} 位分叉: f{browser_val[max(0, i - 8):i 8]} vs f{local_val[max(0, i - 8):i 8]}) return print(完全一致)参数说明长度比对是最快的排除法能在十秒内判断是「结构错」还是「数值错」分叉位置比对适合定位到具体字段如果分叉点落在随机串那一段说明随机串是你自己造的、没有从浏览器取那属于正常现象把随机串也固定住再跑一遍即可。比对通过之后还有一步不能省把固定的时间戳换回真实时间把固定的随机串换回随机生成再发一次真实请求看服务端是否接受。有些站点的校验会额外检查签名里的时间戳和请求头时间差不能超过若干秒本地跑通但请求被拒八成是这个问题。5. 避坑清单五个让我返工重来的常见问题5.1 环境与定位阶段踩的坑现象一断点打在 XHR 上翻遍调用栈全是框架源码找不到任何签名相关的东西。原因是签名逻辑在更早的地方就算完了请求发出去的时候参数只是被塞进 config 对象。解决办法是往前找触发点——从点击事件、路由变化、或者表单提交的入口下手把断点设在事件回调的第一行然后单步跟到发起请求为止。现象二控制台直接调__webpack_require__报未定义。原因是这个加载器从来就不是全局变量它只是每个模块函数作用域里的第三个形参。正确做法是在任意模块函数体首行断住从作用域里把它取出来挂到 window 上再继续操作。这一条我前后栽过两次第二次是因为页面改版后变换了模块注入方式连断点位置都得重新找。现象三本地跑出来的签名长度对得上但内容全不一样且每次跑结果都变。原因是代码里有Math.random()或者Date.now()没有被固定导致每次输入都不同。解决办法是在补桩阶段就把这两个函数替换成返回固定值的版本等整体比对通过后再放开。这条看着简单但实际排查时很容易被忽略因为报错信息里根本不会提到随机数。5.2 还原与验证阶段踩的坑现象四明明算法抄得一模一样结果就是差几位而且只在特定输入下才出现。原因是边界条件没对齐最常见的是空值参与拼接和数字类型被隐式转换。JS 里undefined拼进字符串会变成字面量Python 里会直接抛异常JS 里1和1拼出来一样但走哈希前的类型判断可能不同。解决办法是拿一组包含空值、零值、长文本的参数做对照测试把每种边界都单独跑一遍。现象五昨天还能跑的代码今天全部失败。原因是站点更新了打包产物模块 id 或者盐值变了。这类站点的签名逻辑本身很少大改改的多半是常量或者字段顺序。应对办法是把所有可变部分抽成配置——模块路径、盐值、字段顺序、分隔符全部外置更新时只改配置不改代码。注意还原出来的签名代码只用于自己学习和小规模授权测试别拿去高频打人家的接口。真要做数据采集先看 robots 和平台协议控制频率比研究签名更值得花时间。6. 稳定复用把一次性还原变成可维护的签名模块走到这一步多数人的做法是把跑通的脚本存成一个文件下次要用再翻出来改。我一开始也这么干结果三个月后回头看连自己写的补桩代码都不敢动——因为不知道哪一行是必需的、哪一行是当年调试留下的垃圾。后来我把结构固定成三层最外层是纯参数拼装中间层是算法调用最内层是环境桩三层之间只通过明确的函数签名交互。这样一来站点改版时我只需要看最外层和桩层算法层几乎不用碰。桩层还有一个实用做法把每个桩的返回值都写进一个 JSON 配置文件桩函数只负责读配置。这样下次遇到指纹变化改配置文件就行不用动代码。我一般会在配置文件里额外留一个 UA 字段和一个设备标识字段这两个是最常变、也最容易被忘的。再说回定位效率。我现在处理任何 webpack 站点的签名都强制走一遍固定流程先花五分钟抓三次包把字段分段搞清楚再花五分钟在加载器上挂断点拿到模块表然后用Object.keys扫一遍模块路径找出可疑文件名最后才动断点和搜索。这套顺序的好处是每一步都有明确产出卡住了就知道卡在哪一环而不是在几千行压缩代码里瞎撞。血泪经验是——跳过前面任何一步直接去搜关键词最后都会绕回来重做。最后留一个我认为最有价值的习惯每还原一个站点都把「字段结构说明 依赖树 比对样例」写成一份不超过一页的笔记和代码放在一起。半年后你打开这个目录能在一分钟内回忆起当初为什么这么写。这套东西真正的成本从来不是算法而是记忆。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。