
简介这份面向爬虫与前端安全学习者的实战代码包围绕某东 Webpack 方式下的 h5st 逆向展开提供从模块定位、加载逻辑分析到参数生成调用的一套可运行示例适合已具备 JavaScript 基础、希望理解 h5st 加密流程与请求参数构造的中级学习者参考。压缩包共 2 个文件包含 1 个 Python 脚本与 1 个 JavaScript 文件体量约 159KB前者便于串联请求与参数调用后者承载 Webpack 模块及逆向分析代码结构精简、便于对照调试。目前已有 860 人学习说明该案例在爬虫参数逆向场景中具有一定参考价值。读者可从中获得 h5st 相关逻辑的完整代码、Webpack 打包模块的定位与调用思路以及 Python 与 JS 协同验证的排错方向用于理解目标接口参数生成机制并提升逆向分析能力。1. 一次 403 的排查现场h5st 为什么不能“抄”只能“复刻”请求头里那串带分号的值一变接口立刻从 200 掉到 403——这是我在一个 webpack 打包的移动端页面里第一次碰到 h5st 类签名参数时的现场。它不像 cookie 那样躺在document.cookie里可以直接复制而是每次请求前由一段压在 chunk 里的 JS 现算出来函数名是单字母依赖全挂在闭包里。做爬虫的同学最容易在这里走偏以为找到“加密函数”就赢了实际上真正要复刻的是 webpack 的模块运行时加它依赖的浏览器环境。这篇笔记写给两类人一类是已经会抓包、会写基础请求、卡在签名参数怎么落地这一步的爬虫从业者另一类是想搞清楚 webpack 产物结构、想看看别人怎么拆包的前端同学。下面按“定位 — 抠模块 — 补环境 — 对参数 — 排错 — 回归”的顺序走一遍代码都能直接跑坑也都会点出来。文中所有站点、目录、版本号均为脱敏示意方法用于自建靶场与授权范围内的安全研究。2. 拆 webpack 运行时三种产物形态与模块表的落盘方式逆向这类签名的第一步不是看算法是看“东西装在哪”。webpack 的产物有个固定套路一个运行时runtime负责模块注册和加载若干个 chunk 负责业务代码业务代码被打成模块id - 工厂函数的键值对。签名函数只是其中某个工厂函数里的一段逻辑你拿不到运行时就拿不到它。我一般先把页面所有 JS 落盘再坐回来看结构。下面这几条命令能在一分钟内判断产物形态如果匹配到webpackChunk前缀基本是 webpack 5匹配到webpackJsonp是 webpack 4 及更早两个都没有且文件是 ESM 直出的多半是 vite/rollup那这套方法要换思路。2.1 先判断是哪一代 webpack 产物# 把页面所有 js 落到 ./js 目录后先做形态判断 grep -ro webpackChunk[a-zA-Z0-9_]* ./js | sort -u # webpack 5 grep -ro webpackJsonp[a-zA-Z0-9_]* ./js | sort -u # webpack 4 及以下 grep -rl __webpack_require__ ./js # 运行时所在文件-r递归目录-o只输出命中片段而不是整行压缩后的 JS 一行几万字符不加-o会把整行刷屏sort -u去重。命中的那个名字就是挂载在window/self上的全局数组名后面 hook 要用到它。需要提醒的是同一个站点可能同时存在多个全局名——主站一个、活动页一个、第三方 SDK 一个。别只 hook 第一个命中的把所有命中的名字都列出来逐个挂再对比哪个里面有签名相关模块。我在一个项目里就因为只挂了主站那个白找了两天签名其实在活动页那个独立 chunk 里。2.2 拦截 chunk 注册把模块表完整落盘webpack 5 的 chunk 加载是往全局数组push一个三段式参数[[chunkId...], {模块id: 工厂函数}, 运行时回调]。直接在页面里覆盖push经常失效因为 webpack 内部缓存了数组引用的原始方法用Proxy代理get更稳。// 注入到目标页面控制台拦截模块注册把工厂函数收集起来 (function () { const NAME webpackChunk_shop; // 换成上一步 grep 出来的全局名 const raw window[NAME] || []; window.__modTable {}; // 模块id - 工厂函数源码 window[NAME] new Proxy(raw, { get(target, prop) { if (prop push) { // 参数结构[ [chunkIds], { id: factory }, runtimeCallback? ] return function (arg) { const mods arg[1] || {}; for (const id of Object.keys(mods)) { window.__modTable[id] mods[id].toString(); // 存源码别存函数 } console.log([capture] chunk, arg[0], 模块数, Object.keys(mods).length); return Array.prototype.push.apply(target, arg); }; } return Reflect.get(target, prop); } }); })();这里有个关键选择存mods[id].toString()而不是函数对象。因为函数对象绑死在页面上下文里你没法直接搬到 Node 里源码才是可搬运的资产。另外 webpack 5 的模块 id 可能是数字也可能是字符串src/utils/sign.js这种不要做Number(id)强转强转会让一部分模块查不到报错信息还特别误导后面避坑章节会细说。触发方式很简单在页面上正常点几下、翻两页、触发一次带签名的请求__modTable就会攒满。然后copy(JSON.stringify(window.__modTable))导出成文件。2.3 定位签名入口从请求头往回追调用栈模块表有了但哪个模块是签名模块两个办法我一般同时用。第一个是 DevTools 的 XHR/fetch 断点Sources 面板里加一个 URL 关键字断点命中后看 Call Stack从send往上翻业务层一般在 3 到 6 层之内那一层的函数名往往就是sign、getH5st、_sign之类被保留或半混淆的名字。第二个更快直接 hook 请求头写入把调用栈打出来// hook setRequestHeader谁写的签名头就打谁 const rawSet XMLHttpRequest.prototype.setRequestHeader; XMLHttpRequest.prototype.setRequestHeader function (k, v) { if (/h5st|sign|token|auth/i.test(k)) { console.log([header], k, String(v).slice(0, 60)); console.log(new Error(trace).stack); // 压缩代码里是行列号需 pretty print } return rawSet.apply(this, arguments); };Error.stack在压缩后的代码里只有行列号看着像天书所以要先在 Sources 里点一下{}Pretty print把文件格式化再用 CtrlShiftF 全局搜签名参数的分隔符比如;或版本号字符串通常能直接跳到拼装位置。这一步不要省很多人失败就失败在“猜错了入口模块”后面补环境补半天全补在无关模块上。2.4 手写一个最小__webpack_require____webpack_require__是运行时闭包里的私有函数外部拿不到。既然已经拿到了模块表索性自己写一个加载器逻辑很短但要补齐 webpack 的辅助函数否则 ES Module 互操作会直接崩。// runner.js最小 webpack 模块加载器 const modules require(./modTable.json); // id - 工厂函数源码字符串 const cache {}; function __webpack_require__(id) { if (cache[id]) return cache[id].exports; const module (cache[id] { id: id, exports: {}, loaded: false }); const src modules[id]; if (!src) throw new Error(module not found: id); // 用 Function 把源码还原成工厂函数注意参数顺序固定 const factory new Function(module, exports, __webpack_require__, return ( src )); factory(module, module.exports, __webpack_require__).call( module.exports, module, module.exports, __webpack_require__ ); module.loaded true; return module.exports; } // webpack 5 的四个辅助函数缺一个就可能报 is not a function __webpack_require__.d (exports, name, getter) { if (!Object.prototype.hasOwnProperty.call(exports, name)) { Object.defineProperty(exports, name, { enumerable: true, get: getter }); } }; __webpack_require__.r (exports) { if (typeof Symbol ! undefined Symbol.toStringTag) { Object.defineProperty(exports, Symbol.toStringTag, { value: Module }); } Object.defineProperty(exports, __esModule, { value: true }); }; __webpack_require__.n (m) (m m.__esModule ? () m.default : () m); __webpack_require__.o (obj, key) Object.prototype.hasOwnProperty.call(obj, key); module.exports __webpack_require__;几个参数的用意说明白.d处理export { x }的 getter 定义.r打 ES Module 标记.n处理import * as的兼容包装.o是hasOwnProperty的别名。这四个函数在压缩后的运行时里名字是乱的但用法固定照抄即可。new Function里那个return (...)的括号不能少工厂函数源码本身是function(...){...}形式包一层括号才能被正确求值。写到这一步模块能加载了但一执行大概率会报window is not defined——因为签名逻辑天生依赖浏览器环境。这正是下一章要处理的事。3. 抠出签名模块vm 沙箱补环境与依赖递归收集直接把整个模块表丢进 Node 里跑通常活不过三秒。原因有两层一是模块之间的依赖需要从入口递归拉齐二是模块内部大量访问window、document、navigator这类只有浏览器才有的对象。补环境不是把浏览器整个搬过来而是“缺什么补什么、补的每一块都要有依据”。3.1 从入口模块递归收集依赖签名函数不会孤立存在它可能依赖工具函数模块、MD5 实现模块、时间格式化模块。webpack 的依赖调用在源码里表现为__webpack_require__(123)这样的字面量扫一遍就能拿到依赖图。// 从入口 id 出发把可达模块全部收集出来 function collect(entryId, modules) { const seen new Set(); const stack [entryId]; while (stack.length) { const id stack.pop(); if (seen.has(id)) continue; seen.add(id); const src modules[id] || ; // 匹配 __webpack_require__(123) 或 __webpack_require__(src/a.js) const re /__webpack_require__\(\s*(?:([^])|([^])|(\d))\s*\)/g; let m; while ((m re.exec(src))) { const dep m[1] || m[2] || m[3]; if (dep ! undefined) stack.push(dep); } } return Array.from(seen); }注意正则里同时兼容了字符串 id 和数字 id并且取到的是原始字符串而不是转成数字——原因前面提过webpack 5 的模块 id 可能是路径字符串。收集完之后把结果和modTable.json做交集交集之外的模块就是“不需要搬”的能显著缩小沙箱的攻击面也少补一堆没用的 DOM API。还有个细节有些模块是通过__webpack_require__.e(chunkId)做异步加载的这种静态扫描扫不到。遇到e调用要么手动把对应 chunk 的模块表合并进来要么在沙箱里把__webpack_require__.e打桩成返回已 resolve 的 Promise。后者更快但前提是被异步加载的那个模块确实不影响签名结果——这一点要靠输出比对来验证不能凭感觉。3.2 用 vm 搭一个最小可执行沙箱Node 内置的vm模块是最省事的选择能隔离全局污染又能自由控制上下文里有什么。关键在于window必须指向沙箱自身很多库会写window.xxx ...然后从window上读回来。const vm require(vm); const crypto require(crypto); const sandbox {}; sandbox.window sandbox; sandbox.self sandbox; sandbox.globalThis sandbox; sandbox.top sandbox; sandbox.parent sandbox; // UA 必须从真实请求头里抄别用默认值很多分支靠它判断平台 sandbox.navigator { userAgent: Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148, platform: iPhone, language: zh-CN, languages: [zh-CN], cookieEnabled: true, webdriver: false }; sandbox.location { href: https://example.com/m/index.html, protocol: https:, host: example.com, hostname: example.com, search: , hash: }; sandbox.document { cookie: , // 有需要就整串贴进来 referrer: https://example.com/, createElement: () ({ getContext: () null, style: {}, setAttribute() {} }), getElementsByTagName: () [], querySelector: () null, addEventListener: () {} }; sandbox.localStorage { _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] String(v); }, removeItem(k) { delete this._d[k]; } }; sandbox.crypto crypto.webcrypto; sandbox.setTimeout setTimeout; sandbox.clearTimeout clearTimeout; sandbox.console console; vm.createContext(sandbox); // 之后所有模块都在这个上下文里跑每一块都有理由navigator.userAgent决定走 PC 分支还是 H5 分支签名算法在不同分支下可能完全不同document.cookie里通常有会话标识签名函数会读它参与摘要计算localStorage里常存设备指纹或上一次的令牌location参与某些拼接。这些值都不能拍脑袋编最好的来源是抓包时那条真实请求对应的页面状态——打开 DevTools 控制台把navigator.userAgent、document.cookie、localStorage逐条打印出来复制过去。注意crypto.webcrypto的subtle接口全是 Promise而不少站点签名用的是自研的同步 MD5/SHA256。沙箱里挂了 webcrypto 之后如果原逻辑走的是subtle.digest返回值会变成[object Promise]被拼进字符串结果看着像乱码实际是类型错了。3.3 用 Proxy 打桩把“缺什么”变成日志补环境最耗时的不是写实现是找“还缺什么”。硬跑只会拿到第一个报错改完再跑又报下一个来回几十轮。我的做法是先挂一个万能桩任何未定义的属性访问、函数调用都记录下来并返回桩本身跑一遍就能拿到完整缺失清单。const missing new Set(); function stub(path) { const fn function () {}; return new Proxy(fn, { get(t, p) { if (p Symbol.toPrimitive) return () 0; // 参与运算时不炸 if (p toString) return () ; missing.add(path . String(p)); return stub(path . String(p)); }, apply() { missing.add(path ()); return stub(path ()); }, construct() { missing.add(new path); return stub(new path); } }); } // 遍历沙箱里没有的全局名时用它兜底 sandbox.__stub stub; process.on(exit, () { console.log(缺失清单\n Array.from(missing).sort().join(\n)); });跑一遍之后按清单逐条补真实实现补完再跑直到清单不再增长。这套流程看起来笨但比一轮轮试错快得多而且留下的清单本身就是文档——下次版本更新再挂一次差异一目了然。签名算法里常见的摘要函数如果是自研的直接在沙箱里挂一个同步实现即可不用去动crypto// 同步 md5 挂载示例用现成库别自己手写 const md5 require(crypto-js/md5); sandbox.__md5 (s) md5(String(s)).toString();把模块源码里的crypto.subtle调用替换成__md5属于侵入式改造能做但不推荐长期用——每次版本更新都要改一遍。更稳的是在沙箱里伪造一个同步版的crypto.subtle.digest让原逻辑无感通过改造点集中在一处。这个取舍在长期维护时差别很大后面第 6 章还会说到。4. 拼装参数与冻结变量六个分段字段各管什么签名函数能跑通只完成了一半。真正让请求通过的是“参数拼装得对不对”而这里最容易翻车的地方不是算法是那几个看起来无关紧要的字段。4.1 分段字段的业务含义h5st 这类参数通常是分号分隔的多段结构不同站点段数和顺序不同但语义高度相似段位常见内容作用是否可变第 1 段毫秒或秒级时间戳服务端做过期校验每次必须新第 2 段随机串/客户端指纹防重放部分站点参与摘要可固定调试期第 3 段会话令牌与登录态绑定有 TTL随会话变第 4 段算法版本号服务端据此选校验分支随发版变第 5 段算法标识明文/摘要算法代号一般不变第 6 段摘要值前面各段 请求体的哈希每次变这张表的价值在于当你拿到 403 时可以按段位逐段排查而不是把整个字符串当成黑匣子。我的习惯是先固定第 2、4、5 段只让第 1、6 段变化跑通之后再放开。哪一段一放开就 403问题就锁定在哪一段。第 4 段要特别留意。它是版本号服务端升级后会改而你的脚本里如果写死了旧版本表现是“算法算对了但服务端不认”。稳妥做法是从页面 HTML 里的某个 config 变量或一个独立的配置接口动态取而不是硬编码。4.2 时间戳与随机数让结果可复现的两个开关调试期最痛苦的是“同一个输入两次结果不一样不知道哪次对”。这时候要主动冻结随机源// 调试期冻结 Math.random让签名可复现上线前务必还原 const originalRandom Math.random; let seed 20240521; Math.random function () { seed (seed * 1103515245 12345) 0x7fffffff; return seed / 0x7fffffff; }; // 时间戳同理可以先把 Date.now 钉死比对通过后再放开 const originalNow Date.now; Date.now () 1716300000000;这两个替换必须放在模块加载之前执行因为有些模块在初始化阶段就把Date.now或Math.random存进了闭包变量事后替换无效。这一点很反直觉我最早就是因为替换太晚冻了半天没效果。时间戳还有两个隐蔽问题一是精度JS 的Date.now()是毫秒但有些实现要求秒级多三位数直接导致摘要对不上二是时区new Date().getHours()之类的方法受TZ环境变量影响本地跑出来和浏览器跑出来可能差 8 小时。稳妥做法是启动脚本时显式设process.env.TZ Asia/Shanghai别依赖机器默认值。4.3 把签名能力包成 CLI 和服务调试通了之后别让它停在控制台里。包成一个函数再包成 HTTP 服务后续接入爬虫框架就是一行请求的事。// sign-server.js把签名能力暴露成本地 HTTP 接口 const http require(http); const { sign } require(./signer); // 内部完成沙箱创建 模块加载 const server http.createServer((req, res) { const u new URL(req.url, http://localhost); const body u.searchParams.get(body) || ; // 参与签名的请求体 const t0 Date.now(); try { const h5st sign(body); res.setHeader(content-type, application/json); res.end(JSON.stringify({ h5st, cost: Date.now() - t0 })); } catch (e) { res.statusCode 500; res.end(JSON.stringify({ error: String(e e.message) })); } }); server.listen(8787, () console.log(sign server on 8787));参数说明body是参与摘要计算的请求体原文必须和实际发出的一模一样——多一个空格、字段顺序换一下摘要就变了这是最常见的 403 来源。返回里带cost方便观察性能签名一次通常几毫秒如果超过 100ms多半是沙箱里某个桩函数在空转。并发要注意签名模块内部常带闭包缓存比如缓存了设备指纹多个请求同时进来可能互相污染。稳妥做法是每个请求新建一个 vm context或者用 worker 池跑。前者简单但慢后者快但要处理进程间通信量不大时没必要上。Python 侧调用就这么简单import requests def get_sign(body: str) - str: r requests.get(http://127.0.0.1:8787/, params{body: body}, timeout5) r.raise_for_status() return r.json()[h5st] print(get_sign({page:1,size:20}))把签名服务和业务请求拆成两个进程的好处是签名服务崩了不影响采集任务重试且签名服务可以单独重启、单独挂日志。生产上我会给它加一个健康检查接口业务侧拿到 500 就熔断别让它把整个任务拖死。5. 避坑与排查五条让签名结果对不上的血泪记录下面这五条都是我在不同项目里真实踩过的按“现象 → 原因 → 解决”写。遇到 403 的时候先按这个顺序过一遍比漫无目的地补环境快得多。5.1 执行环境类的三个坑坑一Cannot read properties of undefined (reading call)现象模块加载到一半直接抛这个错堆栈指向__webpack_require__内部看不出哪个模块缺了。原因模块 id 类型不匹配。webpack 5 里模块 id 可能是数字123也可能是字符串src/utils/a.js我在写加载器时习惯性写了Number(id)结果字符串 id 被转成NaN查表查不到。解决加载器里所有键一律保持原始值不做类型转换modTable.json导出时也保留原样。另外顺手加上if (!(id in modules)) throw new Error(missing id)报错信息要能一眼看出缺哪个模块别让undefined.call这种错误浪费半小时。坑二签名函数返回空串或undefined现象不报错但拿到的签名值是空的或者明显是兜底值。原因模块内部有平台判断比如if (/iPhone/.test(navigator.userAgent))沙箱里navigator是默认值或者压根没挂走了 else 分支直接返回空。解决把真实请求头里的User-Agent完整复制到navigator.userAgent同时补齐platform、language、vendor等字段。判断依据不能靠猜去页面控制台把整个navigator的属性打印出来对比。这一步做完很多“算出来是空”的问题自动消失。坑三异常被 try/catch 吞掉只看到一个兜底值现象没有任何报错但结果就是不对。原因签名函数外层包了try { ... } catch (e) { return fallback }异常被吃掉返回了预先准备的默认串。解决沙箱里注入一个会打日志的console并且在vm.createContext之前把Error的prepareStackTrace挂上更直接的办法是临时把抓到的模块源码里的catch分支改掉让它throw出来。这个操作只用于调试别带进最终脚本。改完立刻就能看到真实异常。5.2 参数与时间类的两个坑坑四本地跑得好好的一上线就 403现象同一份代码本地连续跑几十次都通部署到服务器后成功率暴跌。原因两个可能一是服务器时区不是东八区new Date()相关逻辑偏了 8 小时二是服务器时间本身有漂移与服务端的时间窗对不上。第二点更隐蔽容器里 NTP 没同步的情况很常见。解决启动脚本强制process.env.TZ Asia/Shanghai再在签名前后打印本地Date.now()与服务端响应头的Date算个差值超过一两分钟就先校时。我现在的习惯是每次部署完先跑一个十次的连通性小脚本通过率不是 100% 就不继续。坑五第一次调用正常第二次结果就变了现象单独调用没问题连续调用时偶发失败。原因模块闭包里缓存了可变状态。常见的是缓存了设备指纹、自增计数器、上一次的随机种子。第一次初始化之后后续调用复用了旧状态导致与服务端期望的不一致。解决要么每次签名新建 vm context 重新走一遍模块初始化要么把缓存对象在每次调用前重置。前者的代价是性能后者的代价是容易漏字段。我一般先用前者保证正确性压测发现性能不够再逐个优化。判断是不是这个原因很简单连续调两次把两次的中间变量打出来对比哪里不一样就是哪里被缓存了。提示排查时先做“可复现”再做“定位”。把时间戳和随机数冻结住让同样的输入产生同样的输出后面的二分排查才有意义否则你连两次失败是不是同一个原因都说不清。6. 回归与维护把还原脚本变成能长期跑的资产签名逻辑是会变的这一点必须提前接受。所以真正决定这套东西能活多久的不是算法还原得多漂亮而是你有没有一套能快速发现“变了”和快速定位“变在哪”的机制。我一般会维护一张对照表每次版本更新后先跑回归字段用途采集方式页面 JS 文件哈希判断是否发版每次启动抓一次主页算 md5签名模块 id判断模块是否重排从 modTable 里按关键字匹配算法版本段判断服务端要求是否升级解出签名参数第 4 段对比单次签名耗时判断沙箱是否退化服务内部打点连通成功率最终判据每小时跑十次低于 90% 告警这张表不需要多复杂一个 json 文件加几十行脚本就够。关键是把它挂在定时任务上别等业务报错了才去看。回归脚本的核心思路是“冻结输入 比对结构”而不是比对最终字符串——因为时间戳每次都变字符串永远对不上。有效做法是固定时间戳和随机数只校验摘要段长度、字符集、分段数量再配合一次真实请求的成功率做最终判断。// 回归检查结构校验 连通性抽检 const assert require(assert); const { sign } require(./signer); // 冻结时间后同一输入的结构必须稳定 const s sign({page:1,size:20}); const parts s.split(;); assert.ok(parts.length 5, 分段数量变了可能服务端升级); assert.ok(/^[0-9a-f]{32,64}$/i.test(parts[parts.length - 1]), 摘要段格式变了); console.log(结构校验通过段数, parts.length);结构校验能抓到大部分“发版了”的场景而且误报率低。真正的玄学在成功率上有时候结构没变、算法没变就是过不了多半是环境指纹或风控策略调整这种只能靠成功率监控兜底靠肉眼是看不出来的。最后说个我自己的教训。早期我图省事把补环境的代码和业务请求写在一个文件里结果版本一更新改一处带动三处排查半天。后来强制自己拆成三层env/只放环境桩runtime/只放模块加载signer.js对外只暴露一个sign(body)。从那以后我每次更新签名逻辑都强制走一遍“抓新 JS → 生成 modTable → 跑结构校验 → 抽检十次成功率”这四步不再凭感觉改代码。这套流程笨但它让我少熬了很多夜。希望帮到你。本文还有配套的精品资源点击获取