资讯动态

Webpack逆向实战:从打包产物中还原h5st签名生成逻辑

发布时间:2026/10/14 15:07:33 来源:尧图企业网站定制
简介面向爬虫与前端逆向学习者的某东平台Webpack打包版H5ST逆向破解完整代码包聚焦解决接口请求中H5ST参数从何而来、如何生成、怎样在本地复现等问题。资源包含2个文件分别为Python脚本与JavaScript脚本前者负责调用流程与结果验证后者用于还原webpack模块中H5ST的构造逻辑压缩包仅159KB结构精简适合有JavaScript基础和基本逆向概念的学习者快速上手。已有855人学习下载足见其话题热度。通过学习可掌握快速定位加密参数入口、借助浏览器DevTools分析模块依赖、断点调试webpack导出函数等关键技巧并获得可直接运行的示例代码辅助理解H5ST的算法生成与拼接规则脚本内关键位置附有注释便于对照调试与二次改造。对于希望在实际抓包过程中突破参数限制、构建合规数据采集流程的爬虫工程师而言这是一份难得的实战参考。1. 某东接口突然要求 h5stwebpack 逆向这条路为什么可行假设你维护着一个商品数据采集脚本稳定跑了半年某天接口突然不再返回 JSON而是返回一个要求滑块验证的页面。抓包对比后发现所有接口都多了一个叫 h5st 的参数每次请求的值都不一样它由页面里的 JavaScript 实时计算。此时三条路摆在面前无头浏览器、模拟点击过验证、把生成 h5st 的那段 JS 从 webpack 打包产物里完整还原出来。前两条成本高且不稳第三条正是本文要讲的 webpack 方式某东的页面脚本用 webpack 打包h5st 生成代码藏在几千个模块之一你需要定位它、搬进 Node.js、补上浏览器环境最终得到一份可独立调用的完整代码。这条路线适合做商品数据采集、自动化比价、接口稳定性验证的开发者。逆向 h5st 不是玄学它是结构分析加环境模拟每一步可复现。下面按真实落地顺序拆解定位模块、断点拆链、提取代码、补环境、回归验证。2. 在 webpack 打包产物里定位 h5st 模块runtime 结构与两轮筛选2.1 webpack 运行时组织方式modules 数组、webpack_require与模块 IDwebpack 打包产物最外层是一个立即执行函数所有业务代码被塞进一个模块表模块表要么是数组下表对应模块 ID要么是以路径为 key 的对象。无论哪种形态每个模块都是一个函数签名统一是(module, exports, require)。运行时的核心__webpack_require__接收模块 ID首次加载时执行对应模块函数并把结果放进缓存。// 简化版 webpack runtime模块表 require 函数 (function(modules) { var moduleCache {}; function __webpack_require__(moduleId) { if (moduleCache[moduleId]) return moduleCache[moduleId].exports; var module { exports: {} }; moduleCache[moduleId] module; modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); return module.exports; } return __webpack_require__(0); })([ /* 模块 0 */ function(module, exports, __webpack_require__) {}, /* 模块 1 */ function(module, exports, __webpack_require__) {} ]);生产环境的压缩代码里函数名被替换成e、t、a模块间通过a(123)这种调用互相引用整个 bundle 被压缩成一行上万字符的长串。你不需要读懂每一行只抓三个特征外层是 IIFE、内部是巨大的模块表、表项都是函数。这三点在几乎所有 webpack 版本里都稳定存在也是 webpack 方式比直接追源码片段更可靠的原因——它是结构化产物不是靠猜。选 webpack 路线的另一个理由是环境边界清晰。浏览器里跑的是同一套打包产物你在 Sources 面板里看到的目标模块代码就是 Node 里即将执行的代码不存在二次加密或壳层把模块函数搬过来配上 runtime 就能跑。这个特性决定了 h5st 逆向的可行性和可复现性。2.2 第一轮关键字筛选用特征字符串把候选模块缩到个位数定位生成模块最快的方法是搜索特征字符串。打开 DevTools 的 Sources 面板按 CtrlShiftF 打开全局搜索输入h5st。搜索结果通常会落在两个层面一是字符串常量h5st出现的位置那是拼装参数的地方二是变量赋值或函数入参出现的位置那是生成逻辑的入口。搜索结果可能散落在十几个文件里。先看文件路径排除第三方库和无关 chunk再把鼠标悬停在匹配行看上下文是否出现时间戳、指纹、签名相关变量。如果搜到的位置引用了 md5 或 sha256 之类的摘要工具函数顺着调用的模块 ID 往下追。我一般先看拼装层因为那里会直接出现h5st这个 key 名而生成层常常只有缩写变量名不容易一眼确认。一个提高命中率的技巧不要只搜h5st本身。试着搜H5ST、h5st_、h5st:这些变体平台在迭代中可能改过命名规范把大小写和前缀后缀都覆盖到。搜索结果里如果有JSON.stringify或数组join的调用那很可能是签名串拼接的位置值得优先跟进。2.3 第二轮堆栈定位异步 chunk 与延迟加载场景怎么处理第一轮搜索有时搜不到任何结果。这不是死路而是说明 h5st 生成逻辑在异步 chunk 里。webpack 会把按需加载的模块单独打成小文件用户触发接口时才通过webpackJsonp机制注入。直接在全局源码里搜关键字自然搜不到。处理方法先触发一次真实请求在 Network 面板按 JS 类型过滤找到请求时新增加载的.js文件打开再搜h5st大概率一击命中。文件太大不好搜时回到 Sources 面板在发请求的位置打断点命中后看 Call Stack找到计算函数所属文件的 URL。还有一种更笨但可靠的方式在 Network 面板把所有 js 文件保存到本地用编辑器做批量搜索。这个方法不受断点命中率影响缺点是慢一点。动态 chunk 通常体积不大几分钟内能定位完。定位到文件后继续用 2.2 的方法锁定模块 ID定位阶段就算完成了。3. 断点拆算法链从接口调用点一路回溯到签名函数3.1 断点组合XHR 断点先停住关键字断点再聚焦找到文件之后要弄清楚 h5st 到底怎么算出来的。在 Sources 面板右侧的 XHR/fetch Breakpoints 区域添加一条规则URL 包含目标接口路径这样页面一发请求代码就会自动停住。断点命中后右侧 Scope 面板能看到当前作用域的所有变量其中一定有已经生成好的 h5st 值。这时先别急着读代码。在 Console 里把当前 h5st 值打印出来再往上翻调用栈。调用栈每一层都能点击跳转目标是找到真正执行签名计算那一层。中间会经过很多 webpack runtime 包装函数可以直接跳过。一个实用技巧右键点击无关脚本选择 Add script to ignore list把第三方库和 runtime 收进黑名单调用栈会瞬间干净。XHR 断点的粒度大精确度不够时配合关键字条件断点使用。在 Sources 面板打开目标脚本找到往请求里塞 h5st 的位置右键行号添加条件断点条件写成typeof h5st ! undefined。这样只有当 h5st 变量就绪时才会停下比在每行都断下来回试高效得多。3.2 调用栈分析把 h5st 的拼装、指纹、签名三步分开h5st 生成链拆开看基本是三步时间戳与业务参数拼装、设备指纹提取、签名计算。这三步在调用栈里连续出现但压缩代码函数名全是乱码没法靠名字区分。我的做法是逐个函数点进去不读完整逻辑只看两件事它读入了哪些变量、输出了什么类型的结果。判断方法很直接读到Date.now()或new Date().getTime()的是时间戳层读到document.cookie或navigator.userAgent的是指纹层调用某个加密函数且输出是一串十六进制或 Base64 字符串的是签名层。分工明确后给每层函数在代码里加上注释命名。Sources 面板支持直接编辑脚本改完刷新页面断点会命中修改后的代码不需要重新打包。这里有个经验之谈别指望把每一步的数学细节全看懂。h5st 的签名过程大概率是摘要算法加固定拼接你要复现的是「输入什么、按什么顺序拼、最后调什么函数」而不是理解这个签名为何这样设计。黑匣子按黑匣子处理把输入输出对齐就够了。3.3 修改变量名与插桩让压缩代码变成可读源码压缩代码单行上万字符直接读会疯。先点 Sources 面板左下角的 Pretty Print 按钮把单行代码展开成多行。展开后把关键变量名按语义替换成可读名字比如e改成timestampt改成signStr。改名不影响执行逻辑只是让推导过程省力。改完名字后在签名函数入口和出口各加一行console.log打印入参和返回值。刷新页面多触发几次请求把每次的输入、输出记录成数据样本。记录至少三组字段包括业务参数、时间戳、指纹字符串、最终 h5st 值。这三组样本是后续回归测试的地基也是判断 Node.js 侧还原是否正确的标准尺子。插桩还有一个用途验证参数来源。有的版本会把业务请求的方法和路径拼进签名串你需要在生成函数里看清楚 method 和 url 是从哪里读的。如果它来自某个全局变量说明在 Node 侧还原时也要先给这个全局变量赋值。插桩阶段多打几条日志后面少踩好几个坑。4. 完整代码实战提取模块、补全环境、在 Node 里跑通生成函数4.1 模块提取脚本从 bundle 中按 moduleId 把代码搬到 Node逆向到这里手里已经有模块 ID、生成函数代码和至少三组请求样本。开始搭建 Node.js 侧的运行环境。先把模块代码从浏览器里完整复制出来在 Sources 面板打开 bundle 文件定位到目标模块从function(module, exports, __webpack_require__)开始到对应右花括号整段复制。// h5st_runtime.js —— 模块容器与最小 webpack require // 从浏览器里复制的模块代码按 ID 存放在 moduleStore 里 const moduleStore { 67442: function(module, exports, __webpack_require__) { // 把浏览器里复制的模块代码完整粘贴到这里 // 压缩代码保持原样不要手动格式化 } // 缺失的依赖模块按报错提示逐个补进这个对象 }; // 最小 webpack runtime缓存 加载 执行 const moduleCache {}; function _require(moduleId) { const cached moduleCache[moduleId]; if (cached) return cached.exports; const module { exports: {} }; moduleCache[moduleId] module; moduleStore[moduleId](module, module.exports, _require); return module.exports; } // 加载目标模块确认它的导出对象上有什么方法 const h5stModule _require(67442); console.log(模块导出内容:, Object.keys(h5stModule));这段代码里_require模拟了 webpack 的核心机制模块只执行一次结果走缓存。如果 bundle 用路径字符串做模块 ID把moduleStore的 key 改成对应路径即可_require逻辑不用变。粘贴模块代码后最常碰到的错误是_require is not defined压缩代码里的a(n)调用原本指向 webpack 的 require你得保证_require的名字和模块内部引用的名字一致。模块里写的是a(n)就把_require赋值给变量a或者在外层包一层适配。为了理清模块依赖范围在_require里加一行统计代码把请求过的模块 ID 记下来跑一次生成调用后打印。这张依赖清单就是补模块的路线图。// 依赖探测记录所有被加载的模块 ID const askedIds new Set(); function _require(moduleId) { askedIds.add(moduleId); // 原逻辑不变 } // 调用一次生成函数后打印 console.log(依赖模块列表:, [...askedIds].sort((a, b) a - b));askedIds会在第一次加载模块的过程中被自然填充因为生成函数内部必然调用_require去取其他模块。你拿到的列表就是这一套算法完整的模块依赖图对照moduleStore现有的 key一眼就能看出缺口。4.2 宿主环境 shimwindow、document、crypto 补齐模块在原页面运行天然依赖浏览器宿主对象搬进 Node.js 后这些对象全都不存在。好在 h5st 模块用到的宿主对象通常有限window、document、navigator、location、crypto、performance几类。补齐原则是缺什么补什么不要一次性把所有浏览器 API 都填进去。// shim.js —— 浏览器对象的最小声替身 const crypto require(crypto); module.exports { window: {}, document: { cookie: , // 从真实会话里复制指纹相关字段必须保留 getElementById: () null, createElement: () ({ style: {}, setAttribute() {}, appendChild() {} }), readyState: complete, title: }, navigator: { userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, platform: Win32, language: zh-CN }, location: { href: https://item.example.com/10001234.html, host: item.example.com, protocol: https:, search: }, crypto: { getRandomValues(arr) { const bytes crypto.randomBytes(arr.length); for (let i 0; i arr.length; i) arr[i] bytes[i]; return arr; }, randomUUID() { return crypto.randomUUID(); } }, performance: { now: () Date.now() }, console };shim 里最值得注意的是document.cookie。指纹算法会从 cookie 里读取一组字段参与签名cookie 内容不对生成的指纹就是错的签名自然过不了。实际操作时我会先在真实浏览器会话里把请求携带的 cookie 完整复制出来填进 shim 跑通流程之后再逐步精简看哪些字段是必要的。crypto.getRandomValues用 Node 的crypto.randomBytes替代因为很多版本的指纹算法依赖随机数生成随机因子而且要求每次调用不重复。补环境时如果出现xxx is not a function先看报错堆栈里引用的对象名对应补上即可。不要为了省事把所有 API 全填成空函数有些模块加载时会读取属性值参与运算空函数会导致后续计算拿到 NaN 或 undefined排查起来更费劲。4.3 最小调用代码在 Node.js 里生成 h5st 的完整流程// index.js —— 最小可运行示例 const shim require(./shim); Object.assign(global, shim); const h5stModule require(./h5st_runtime); // 确认生成函数的名字常见有 createH5st / generate / sign const createH5st h5stModule.createH5st || h5stModule.generate || h5stModule.default; // 请求参数对象字段来源见下方表格 const params { appId: c0f5xxxx, // 业务标识页面脚本里能搜到常量 method: GET, // 请求方法 url: https://api.example.com/v2/sku, // 请求地址不含 query body: , // 请求体原文 timestamp: Date.now(), // 时间戳必要时用服务端时间校准 random: Math.random().toString(16).slice(2) }; const h5st createH5st(params); console.log(生成的 h5st:, h5st);参数用途获取方式appId业务标识不同接口可能不同页面脚本常量直接搜关键词method请求方法真实请求里可见url签名作用的路径去掉 query 的请求地址body请求体原文POST 请求才需要GET 填空timestamp签名时间戳默认取当前时间建议用服务端时间校准random随机因子部分版本会用到没有就不传这段代码的逻辑顺序是先把 shim 挂到全局再加载模块容器最后调用生成函数。注意Object.assign(global, shim)这一步必须在require(./h5st_runtime)之前完成因为模块加载过程中就可能访问window或document加载完了才补环境会直接抛异常。调用时如果报createH5st is not a function先打印Object.keys(h5stModule)确认模块导出了什么。很多时候模块导出的是对象生成函数在对象内部还要再往下取一层。这一步跑通后还原工作完成八成剩下的就是把生成结果拿去做真实请求验证。5. h5st 逆向常见问题实录最容易翻车的 4 个场景与解法5.1 时间戳与服务端时钟误差为什么本地算出来的一直不通过现象Node 里生成 h5st 一切正常真实请求发过去服务端一直返回验签失败。最典型的坑就是时间戳偏差。签名串里编码了生成 h5st 时的时间戳服务端比对当前时间误差超过一定窗口直接拒绝。原因通常在两个层面生成函数默认时间取new Date()而运行环境的系统时间未校准或时区不对你自己传 timestamp 时用了本地时间没有对齐服务端时钟。解决办法是不要信任本地时钟。先请求平台任意一个不需要 h5st 的公开接口从响应头或响应体拿到服务端时间算好与本地时间的差值再把校准后的时间戳传给生成函数。如果生成函数内部不支持覆盖 timestamp就用补丁方式替换模块里的Date.now让它统一走校准函数。这一步解决后大多数验签失败都会消失。提示时区处理不要只改本地时间把校准差值直接注入生成逻辑比改系统时区更稳。5.2 补模块补出循环依赖与 undefined 调用现象跑生成函数时报错从最初的「模块不存在」变成了Cannot read properties of undefined (reading xxx)。原因是补进去的模块本身还依赖别的模块那些依赖没补全导致某个模块加载成功但导出内容是空对象。压缩代码里这种间接依赖很隐蔽光看报错看不出缺哪个。解决方法是回归依赖列表。用 4.1 里的依赖探测代码把一次完整调用过程中请求过的所有模块 ID 打出来和moduleStore里已有的做差集差的就是缺口。逐个补进moduleStore再跑直到差集为空。遇到循环依赖不要慌webpack 原本的处理方式就是把空 module 先放进缓存再执行模块体_require里moduleCache[moduleId] module这一步就是模拟这个机制别为了省事删掉。5.3 算法版本更新导致整体失效现象某天生成的 h5st 服务端全部不认本地代码和样本回归全都正常但线上就是失败。原因是平台端更新了算法版本签名 key、拼接顺序、模块 ID 都可能变了。这类更新不会提前通知是某个时间点悄然上线属于典型的黑匣子行为。解决办法是把一切版本相关的东西从代码里剥离。模块 ID、appId、签名常量全部抽到配置文件不要硬编码。失效时重新走一遍定位流程找到新模块和新常量只改配置即可恢复。我会在配置里加一个版本号字段每次失效记录平台更新的时间点和对应新版本号积累几轮之后能看出更新频率提前做预案。版本更新是这类逆向工作必须长期面对的问题它不是意外只是还没到那个时间点。5.4 内存态泄漏模块被反复加载后的状态污染现象同一个 Node 进程连续生成多个 h5st第一个成功第二个开始全失败重启进程又恢复。原因大概率是模块内部有可变状态。某些版本把随机因子、指纹结果缓存在闭包变量里第一次调用初始化后续直接复用或者模块初始化时读取全局对象状态而这个状态在多次调用之间变了。解决方案是把模块容器做成工厂函数。每次生成 h5st 时重新创建moduleCache和moduleStore加载完整模块链用完即弃。这样每次都拿到干净初始状态代价只是多几十毫秒初始化时间。生成频率高到对性能敏感时改用 worker thread 或把生成逻辑独立成服务进程隔离天然解决状态污染问题。这个坑最容易在刚跑通代码时忽略因为第一次调用总是对的。6. 用回归测试锁住算法版本拿到更新先不慌算法版本更新后第一件事不是重新逆向而是先确认代码坏在哪个环节。把第三章记录的三组请求样本整理成 JSON 文件字段包含请求参数和当时的 h5st 值。写一个回归脚本每次改动代码后跑一遍旧样本全部通过说明代码没退化旧样本全部失败说明平台换了算法这时再重新定位才划算。// regression.js —— 用历史样本回归验证 const samples require(./samples.json); const { generateH5st } require(./h5st_runtime); let passed 0; for (const s of samples) { const result generateH5st(s.params); if (result s.expected) { passed; } else { console.error(样本 ${s.caseId} 不一致期望 ${s.expected}实际 ${result}); } } console.log(回归结果: ${passed}/${samples.length} 通过);样本采集有三个要求不同时间点、不同接口路径、不同业务参数。这样覆盖的时间戳变化和路径差异才足够全。把回归脚本挂到定时任务里每天跑一次不用等人发现失效脚本自己会告诉你。把配置外置、样本固定、回归自动化这套做法本身的价值不低于逆向代码本身。我自己就有过改了一个环境初始化顺序以为不影响算法结果线上全挂的经历那次之后养成了先跑样本再部署的习惯。h5st 逆向真正的难点不是第一次还原而是长期维护时的高效切换回归测试就是这套工作流的后悔药花十分钟写能省后面十小时。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑